diff --git a/apps/web/src/components/apps/document-viewer-container.tsx b/apps/web/src/components/apps/document-viewer-container.tsx index 0734f47d944..7ee1acdec8b 100644 --- a/apps/web/src/components/apps/document-viewer-container.tsx +++ b/apps/web/src/components/apps/document-viewer-container.tsx @@ -2,7 +2,7 @@ import { ChevronDown, Download, FileText, Loader2, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button, Menu, Typography } from "@vellum/design-library"; -import { exportDocumentPDF } from "@/lib/documents-api"; +import { documentsByIdPdfGet } from "@/generated/daemon/sdk.gen"; export interface DocumentViewerContainerProps { documentName: string; @@ -182,8 +182,13 @@ export function DocumentViewerContainer({ } setIsExportingPDF(true); try { - const pdfBlob = await exportDocumentPDF(assistantId, surfaceId); - if (pdfBlob) { + const { response: pdfResponse } = await documentsByIdPdfGet({ + path: { assistant_id: assistantId, id: surfaceId }, + throwOnError: false, + parseAs: "stream", + }); + if (pdfResponse && pdfResponse.ok) { + const pdfBlob = await pdfResponse.blob(); downloadBlob(pdfBlob, sanitizeFilename(documentName) + ".pdf"); } } finally { diff --git a/apps/web/src/components/apps/library-app-card.tsx b/apps/web/src/components/apps/library-app-card.tsx new file mode 100644 index 00000000000..07f11845b57 --- /dev/null +++ b/apps/web/src/components/apps/library-app-card.tsx @@ -0,0 +1,285 @@ +import { + ArrowUp, + Ellipsis, + Globe, + Pin, + PinOff, + Trash2, +} from "lucide-react"; +import { type MouseEvent, useCallback, useState } from "react"; + +import type { AppSummary } from "@/types/app-types"; +import { getCachedAppHtml } from "@/utils/app-html-cache"; +import { shareApp } from "@/utils/share-app"; +import { AppPreviewThumbnail } from "@/components/app-card"; +import { + BottomSheet, + Button, + Menu, + PanelItem, + toast, +} from "@vellum/design-library"; +import { useIsMobile } from "@/hooks/use-is-mobile"; +import { cn } from "@/utils/misc"; +import { formatLibraryDate } from "@/components/apps/library-date"; + +interface LibraryAppCardProps { + app: AppSummary; + assistantId: string; + isPinned: boolean; + onOpen: (appId: string) => void; + onPin: (app: AppSummary) => void; + onDelete?: (app: AppSummary) => void; + onDeploy?: () => void; + isOpening?: boolean; + justImported?: boolean; + onAnimationEnd?: () => void; +} + +export function LibraryAppCard({ + app, + assistantId, + isPinned, + onOpen, + onPin, + onDelete, + onDeploy, + isOpening, + justImported, + onAnimationEnd, +}: LibraryAppCardProps) { + const [isSharing, setIsSharing] = useState(false); + const loadHtml = useCallback( + () => getCachedAppHtml(assistantId, app.id), + [assistantId, app.id], + ); + const handleShare = useCallback(async () => { + if (isSharing) return; + setIsSharing(true); + try { + await shareApp(assistantId, app.id, app.name); + toast.success("App exported", { description: `${app.name}.vellum` }); + } catch (err) { + toast.error("Failed to share app", { + description: err instanceof Error ? err.message : undefined, + }); + } finally { + setIsSharing(false); + } + }, [assistantId, app.id, app.name, isSharing]); + + const [menuOpen, setMenuOpen] = useState(false); + const isMobile = useIsMobile(); + + return ( +
+ + +
+ onPin(app)} + onDelete={onDelete ? () => onDelete(app) : undefined} + onShare={handleShare} + onDeploy={onDeploy} + isMobile={isMobile} + /> +
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// Actions menu (desktop dropdown + mobile bottom sheet) +// --------------------------------------------------------------------------- + +export interface LibraryAppCardActionsMenuProps { + appName: string; + isPinned: boolean; + open: boolean; + onOpenChange: (next: boolean) => void; + onPin: () => void; + onDelete?: () => void; + onShare?: () => void; + onDeploy?: () => void; + isMobile: boolean; +} + +export function LibraryAppCardActionsMenu({ + appName, + isPinned, + open, + onOpenChange, + onPin, + onDelete, + onShare, + onDeploy, + isMobile, +}: LibraryAppCardActionsMenuProps) { + if (isMobile) { + return ( + + + + + + + ); +} diff --git a/apps/web/src/components/apps/library-view.tsx b/apps/web/src/components/apps/library-view.tsx index 8ba1e7c6904..e2345203720 100644 --- a/apps/web/src/components/apps/library-view.tsx +++ b/apps/web/src/components/apps/library-view.tsx @@ -1,227 +1,45 @@ import { - ArrowUp, - Ellipsis, - FileText, - Globe, LayoutGrid, - Pin, - PinOff, Search, - Trash2, Upload, } from "lucide-react"; -import { type ChangeEvent, type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type ChangeEvent, useCallback, useMemo, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { AppSummary } from "@/lib/apps-api"; -import type { DocumentSummary } from "@/lib/documents-api"; -import { ApiError } from "@/lib/api-errors"; import { - deleteApp, - getCachedAppHtml, - importBundle, - listApps, - openApp, - primeAppHtmlCache, - shareApp, -} from "@/lib/apps-api"; -import { listDocuments } from "@/lib/documents-api"; -import { getVercelConfig, isCredentialError, publishApp } from "@/lib/publish-api"; + appsByIdDeletePost, + appsByIdOpenPost, +} from "@/generated/daemon/sdk.gen"; +import { + appsGetOptions, + appsGetQueryKey, + documentsGetOptions, +} from "@/generated/daemon/@tanstack/react-query.gen"; +import type { AppSummary } from "@/types/app-types"; +import { clearAppHtmlCache, getCachedAppHtml, primeAppHtmlCache } from "@/utils/app-html-cache"; +import { importBundle } from "@/utils/import-bundle"; +import { shareApp } from "@/utils/share-app"; import { usePinnedAppsStore } from "@/stores/pinned-apps-store"; +import { useDeployStore } from "@/stores/deploy-store"; import { useAssistantFeatureFlagStore } from "@/lib/feature-flags/assistant-feature-flag-store"; -import { AppPreviewThumbnail } from "@/components/app-card"; import { - BottomSheet, Button, ConfirmDialog, Input, - Menu, - PanelItem, toast, } from "@vellum/design-library"; import { AppViewerContainer } from "@/components/apps/app-viewer-container"; import { VercelTokenDialog } from "@/components/vercel-token-dialog"; -import { useIsMobile } from "@/hooks/use-is-mobile"; -import { cn } from "@/utils/misc"; - -function formatDate(epochMs: number): string { - const date = new Date(epochMs); - return date.toLocaleDateString(undefined, { - day: "numeric", - month: "short", - year: date.getFullYear() !== new Date().getFullYear() ? "numeric" : undefined, - }); -} - -interface LibraryAppCardProps { - app: AppSummary; - assistantId: string; - isPinned: boolean; - onOpen: (appId: string) => void; - onPin: (app: AppSummary) => void; - onDelete?: (app: AppSummary) => void; - onDeploy?: () => void; - isOpening?: boolean; - justImported?: boolean; - onAnimationEnd?: () => void; -} - -function LibraryAppCard({ - app, - assistantId, - isPinned, - onOpen, - onPin, - onDelete, - onDeploy, - isOpening, - justImported, - onAnimationEnd, -}: LibraryAppCardProps) { - const [isSharing, setIsSharing] = useState(false); - const loadHtml = useCallback( - () => getCachedAppHtml(assistantId, app.id), - [assistantId, app.id], - ); - const handleShare = useCallback(async () => { - if (isSharing) return; - setIsSharing(true); - try { - await shareApp(assistantId, app.id, app.name); - toast.success("App exported", { description: `${app.name}.vellum` }); - } catch (err) { - toast.error("Failed to share app", { - description: err instanceof Error ? err.message : undefined, - }); - } finally { - setIsSharing(false); - } - }, [assistantId, app.id, app.name, isSharing]); - - const [menuOpen, setMenuOpen] = useState(false); - const isMobile = useIsMobile(); - - return ( -
- - -
- onPin(app)} - onDelete={onDelete ? () => onDelete(app) : undefined} - onShare={handleShare} - onDeploy={onDeploy} - isMobile={isMobile} - /> -
- - -
- ); -} - -interface LibraryDocumentCardProps { - document: DocumentSummary; - onOpen: (documentSurfaceId: string) => void; -} - -function formatWordCount(count: number): string { - return count === 1 ? "1 word" : `${count} words`; -} - -function LibraryDocumentCard({ document, onOpen }: LibraryDocumentCardProps) { - return ( -
- - - -
- ); -} +import { LibraryAppCard } from "@/components/apps/library-app-card"; +import { LibraryDocumentCard } from "@/components/apps/library-document-card"; export interface LibraryViewProps { assistantId: string; assistantName?: string; - /** - * Optional page title rendered to the left of the Import action. - * Used when LibraryView is the page's primary content (e.g. the - * standalone /library route) so the title shares a row with Import. - */ title?: string; onNewConversation?: (initialMessage?: string) => void; onOpenDocument?: (documentSurfaceId: string) => void; onEditApp?: (app: { appId: string; dirName?: string; name: string; html: string }) => void; - /** - * If provided, clicking an app navigates instead of opening it inline. - * The library's `/library/:appId` route renders {@link LibraryDetailPage} - * for the dedicated detail view; this callback wires the list click to - * that route. When omitted, the click falls back to the inline overlay. - */ onOpenApp?: (appId: string) => void; } @@ -237,10 +55,25 @@ export function LibraryView({ const deployToVercel = useAssistantFeatureFlagStore.use.deployToVercel(); const pinnedAppIds = usePinnedAppsStore.use.pinnedAppIds(); const togglePin = usePinnedAppsStore.use.togglePin(); - const [apps, setApps] = useState([]); - const [documents, setDocuments] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); + + // Deploy store — shared with chat-page for consistent deploy UX + const isDeploying = useDeployStore.use.isDeploying(); + const isTokenDialogOpen = useDeployStore.use.isTokenDialogOpen(); + const complexDeployApp = useDeployStore.use.complexDeployApp(); + + const { data: apps = [], isLoading: appsLoading, error: appsError } = useQuery({ + ...appsGetOptions({ path: { assistant_id: assistantId } }), + select: (data) => data.apps, + }); + const { data: documents = [], isLoading: docsLoading, error: docsError } = useQuery({ + ...documentsGetOptions({ path: { assistant_id: assistantId } }), + select: (data) => data.documents, + }); + const loading = appsLoading || docsLoading; + const error = appsError && docsError + ? (appsError instanceof Error ? appsError.message : "Failed to load library") + : null; const [searchText, setSearchText] = useState(""); const [openedApp, setOpenedApp] = useState<{ @@ -250,67 +83,12 @@ export function LibraryView({ html: string; } | null>(null); const [openingAppId, setOpeningAppId] = useState(null); - const [appPendingDelete, setAppPendingDelete] = useState( - null, - ); + const [appPendingDelete, setAppPendingDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [isImporting, setIsImporting] = useState(false); const [isSharing, setIsSharing] = useState(false); const [lastImportedAppId, setLastImportedAppId] = useState(null); const fileInputRef = useRef(null); - const [isDeploying, setIsDeploying] = useState(false); - const [showTokenDialog, setShowTokenDialog] = useState(false); - const [pendingDeployAppId, setPendingDeployAppId] = useState(null); - const [complexDeployApp, setComplexDeployApp] = useState<{ appId: string; name: string } | null>(null); - - useEffect(() => { - let cancelled = false; - - async function fetchLibrary() { - try { - setLoading(true); - setError(null); - const [appsResult, docsResult] = await Promise.allSettled([ - listApps(assistantId), - listDocuments(assistantId), - ]); - if (!cancelled) { - if (appsResult.status === "fulfilled") { - setApps(appsResult.value); - } - if (docsResult.status === "fulfilled") { - setDocuments(docsResult.value); - } - if ( - appsResult.status === "rejected" && - docsResult.status === "rejected" - ) { - const isNotFound = (r: PromiseRejectedResult) => - r.reason instanceof ApiError && r.reason.status === 404; - if (isNotFound(appsResult) && isNotFound(docsResult)) { - setApps([]); - setDocuments([]); - } else { - throw appsResult.reason; - } - } - } - } catch (err) { - if (!cancelled) { - setError( - err instanceof Error ? err.message : "Failed to load library", - ); - } - } finally { - if (!cancelled) setLoading(false); - } - } - - void fetchLibrary(); - return () => { - cancelled = true; - }; - }, [assistantId]); const filteredApps = useMemo(() => { if (!searchText.trim()) return apps; @@ -340,10 +118,6 @@ export function LibraryView({ const handleOpenApp = useCallback( async (appId: string) => { - // When wired with a route-based open handler, navigate to the dedicated - // detail page instead of opening inline. LibraryDetailPage handles the - // openApp call + dedicated load/error UI, and the URL becomes the - // shareable deep-link. if (onOpenApp) { onOpenApp(appId); return; @@ -351,7 +125,10 @@ export function LibraryView({ if (openingAppId) return; setOpeningAppId(appId); try { - const result = await openApp(assistantId, appId); + const { data: result } = await appsByIdOpenPost({ + path: { assistant_id: assistantId, id: appId }, + throwOnError: true, + }); primeAppHtmlCache(assistantId, result.appId, result.html); setOpenedApp({ appId: result.appId, dirName: result.dirName, name: result.name, html: result.html }); } catch (err) { @@ -384,80 +161,18 @@ export function LibraryView({ const handleDeploy = useCallback(async (appId: string) => { if (isDeploying) return; + const app = apps.find((a) => a.id === appId); + const appName = app?.name ?? "this app"; try { const html = await getCachedAppHtml(assistantId, appId); - if (html.includes("vellum.fetch") || html.includes("vellum.sendAction") || html.includes("/v1/x/") || html.includes("/v1/apps/")) { - const app = apps.find((a) => a.id === appId); - setComplexDeployApp({ appId, name: app?.name ?? "this app" }); - return; - } + void useDeployStore.getState().deployApp(assistantId, appId, appName, html); } catch { - // If we can't check the HTML, proceed with the deploy anyway - } - setIsDeploying(true); - try { - const config = await getVercelConfig(assistantId); - if (!config.hasToken) { - setPendingDeployAppId(appId); - setShowTokenDialog(true); - setIsDeploying(false); - return; - } - const result = await publishApp(assistantId, appId); - if (!result.success) { - if (isCredentialError(result)) { - setPendingDeployAppId(appId); - setShowTokenDialog(true); - } else { - toast.error("Failed to deploy", { description: result.error }); - } - } else if (result.publicUrl) { - toast.success("Deployed to Vercel", { - description: result.publicUrl, - action: { - label: "Open", - onClick: () => window.open(result.publicUrl, "_blank"), - }, - }); - } else { - toast.success("Deployed to Vercel"); - } - } catch (err) { - toast.error("Failed to deploy", { - description: err instanceof Error ? err.message : undefined, - }); - } finally { - setIsDeploying(false); + // If we can't fetch the HTML, try deploying anyway with empty HTML + // (the store's complexity check will pass, and the server will handle it) + void useDeployStore.getState().deployApp(assistantId, appId, appName, ""); } }, [assistantId, isDeploying, apps]); - const handleTokenSaved = useCallback(async () => { - setShowTokenDialog(false); - const appId = pendingDeployAppId; - setPendingDeployAppId(null); - if (!appId) return; - setIsDeploying(true); - try { - const result = await publishApp(assistantId, appId); - if (!result.success) { - toast.error("Failed to deploy", { description: result.error }); - } else if (result.publicUrl) { - toast.success("Deployed to Vercel", { - description: result.publicUrl, - action: { label: "Open", onClick: () => window.open(result.publicUrl, "_blank") }, - }); - } else { - toast.success("Deployed to Vercel"); - } - } catch (err) { - toast.error("Failed to deploy", { - description: err instanceof Error ? err.message : undefined, - }); - } finally { - setIsDeploying(false); - } - }, [assistantId, pendingDeployAppId]); - const handlePinToggle = useCallback( (app: AppSummary) => togglePin(app), [togglePin], @@ -468,8 +183,12 @@ export function LibraryView({ if (!target || isDeleting) return; setIsDeleting(true); try { - await deleteApp(assistantId, target.id); - setApps((prev) => prev.filter((a) => a.id !== target.id)); + await appsByIdDeletePost({ + path: { assistant_id: assistantId, id: target.id }, + throwOnError: true, + }); + clearAppHtmlCache(assistantId, target.id); + void queryClient.invalidateQueries({ queryKey: appsGetQueryKey({ path: { assistant_id: assistantId } }) }); if (pinnedAppIds.has(target.id)) { togglePin(target); } @@ -489,11 +208,13 @@ export function LibraryView({ setIsImporting(true); try { const result = await importBundle(assistantId, file); - const updatedApps = await listApps(assistantId); - setApps(updatedApps); + await queryClient.invalidateQueries({ queryKey: appsGetQueryKey({ path: { assistant_id: assistantId } }) }); setLastImportedAppId(result.appId); try { - const appResult = await openApp(assistantId, result.appId); + const { data: appResult } = await appsByIdOpenPost({ + path: { assistant_id: assistantId, id: result.appId }, + throwOnError: true, + }); primeAppHtmlCache(assistantId, appResult.appId, appResult.html); setOpenedApp({ appId: appResult.appId, dirName: appResult.dirName, name: appResult.name, html: appResult.html }); setLastImportedAppId(null); @@ -525,10 +246,14 @@ export function LibraryView({ isDeploying={isDeploying} /> { + if (!open) useDeployStore.getState().hideTokenDialog(); + }} assistantId={assistantId} - onTokenSaved={handleTokenSaved} + onTokenSaved={() => { + void useDeployStore.getState().deployAfterTokenSaved(assistantId); + }} /> { - const appName = complexDeployApp?.name ?? "this app"; - setComplexDeployApp(null); + const appName = useDeployStore.getState().complexDeployApp?.name ?? "this app"; + useDeployStore.getState().setComplexDeployApp(null); onNewConversation?.( `Deploy my app "${appName}" to Vercel. It uses backend services that need serverless functions — please use the deploy-fullstack-vercel skill to handle it properly.`, ); }} - onCancel={() => setComplexDeployApp(null)} + onCancel={() => useDeployStore.getState().setComplexDeployApp(null)} /> ); @@ -745,10 +470,14 @@ export function LibraryView({ { + if (!open) useDeployStore.getState().hideTokenDialog(); + }} assistantId={assistantId} - onTokenSaved={handleTokenSaved} + onTokenSaved={() => { + void useDeployStore.getState().deployAfterTokenSaved(assistantId); + }} /> { - const appName = complexDeployApp?.name ?? "this app"; - setComplexDeployApp(null); + const appName = useDeployStore.getState().complexDeployApp?.name ?? "this app"; + useDeployStore.getState().setComplexDeployApp(null); onNewConversation?.( `Deploy my app "${appName}" to Vercel. It uses backend services that need serverless functions — please use the deploy-fullstack-vercel skill to handle it properly.`, ); }} - onCancel={() => setComplexDeployApp(null)} + onCancel={() => useDeployStore.getState().setComplexDeployApp(null)} /> ); } - -export interface LibraryAppCardActionsMenuProps { - appName: string; - isPinned: boolean; - open: boolean; - onOpenChange: (next: boolean) => void; - onPin: () => void; - onDelete?: () => void; - onShare?: () => void; - onDeploy?: () => void; - isMobile: boolean; -} - -export function LibraryAppCardActionsMenu({ - appName, - isPinned, - open, - onOpenChange, - onPin, - onDelete, - onShare, - onDeploy, - isMobile, -}: LibraryAppCardActionsMenuProps) { - if (isMobile) { - return ( - - -