From 067d675c4586066d9985bb72ad63e94b2137ca70 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 19:08:44 +0000 Subject: [PATCH 1/2] refactor(web): eliminate lib/apps-api.ts, documents-api.ts, publish-api.ts (LUM-1964) Replace hand-written API wrappers with generated daemon SDK calls and TanStack Query hooks. Business logic (HTML cache, share-app download chain, import-bundle binary upload, credential error detection) moves to purpose-built utility files under utils/ and types/. Consumers that listed apps/documents now use generated query hooks (appsGetOptions, documentsGetOptions) with select transforms. Mutation consumers call the generated SDK functions directly and invalidate the query cache. Closes LUM-1964 Co-Authored-By: ashlee@vellum.ai --- .../apps/document-viewer-container.tsx | 11 +- apps/web/src/components/apps/library-view.tsx | 134 +++++------- .../src/components/vercel-token-dialog.tsx | 8 +- .../components/conversation-assets-pill.tsx | 76 +++---- .../components/document-viewer-container.tsx | 9 +- .../surfaces/dynamic-page-surface.test.tsx | 3 +- .../surfaces/dynamic-page-surface.tsx | 2 +- apps/web/src/domains/chat/deploy-store.ts | 23 +- .../src/domains/chat/document-viewer-page.tsx | 39 ++-- .../domains/library/library-detail-page.tsx | 11 +- apps/web/src/lib/apps-api.ts | 205 ------------------ apps/web/src/lib/documents-api.ts | 131 ----------- apps/web/src/lib/publish-api.ts | 141 ------------ apps/web/src/stores/pinned-apps-store.ts | 2 +- apps/web/src/stores/viewer-store.ts | 14 +- apps/web/src/types/app-types.ts | 11 + apps/web/src/types/document-types.ts | 8 + apps/web/src/types/publish-types.ts | 23 ++ apps/web/src/utils/app-html-cache.ts | 50 +++++ apps/web/src/utils/app-pin-storage.test.ts | 2 +- apps/web/src/utils/app-pin-storage.ts | 2 +- apps/web/src/utils/import-bundle.ts | 42 ++++ apps/web/src/utils/share-app.ts | 40 ++++ 23 files changed, 355 insertions(+), 632 deletions(-) delete mode 100644 apps/web/src/lib/apps-api.ts delete mode 100644 apps/web/src/lib/documents-api.ts delete mode 100644 apps/web/src/lib/publish-api.ts create mode 100644 apps/web/src/types/app-types.ts create mode 100644 apps/web/src/types/document-types.ts create mode 100644 apps/web/src/types/publish-types.ts create mode 100644 apps/web/src/utils/app-html-cache.ts create mode 100644 apps/web/src/utils/import-bundle.ts create mode 100644 apps/web/src/utils/share-app.ts 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-view.tsx b/apps/web/src/components/apps/library-view.tsx index 8ba1e7c6904..5232a41e331 100644 --- a/apps/web/src/components/apps/library-view.tsx +++ b/apps/web/src/components/apps/library-view.tsx @@ -10,22 +10,26 @@ import { Trash2, Upload, } from "lucide-react"; -import { type ChangeEvent, type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type ChangeEvent, type MouseEvent, 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, + appsByIdPublishPost, + integrationsVercelConfigGet, +} from "@/generated/daemon/sdk.gen"; +import { + appsGetOptions, + appsGetQueryKey, + documentsGetOptions, +} from "@/generated/daemon/@tanstack/react-query.gen"; +import type { AppSummary } from "@/types/app-types"; +import type { DocumentSummary } from "@/types/document-types"; +import { isCredentialError } from "@/types/publish-types"; +import { 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 { useAssistantFeatureFlagStore } from "@/lib/feature-flags/assistant-feature-flag-store"; import { AppPreviewThumbnail } from "@/components/app-card"; @@ -237,10 +241,20 @@ 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(); + + 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<{ @@ -263,55 +277,6 @@ export function LibraryView({ 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; const lower = searchText.toLowerCase(); @@ -351,7 +316,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) { @@ -396,14 +364,20 @@ export function LibraryView({ } setIsDeploying(true); try { - const config = await getVercelConfig(assistantId); + const { data: config } = await integrationsVercelConfigGet({ + path: { assistant_id: assistantId }, + throwOnError: true, + }); if (!config.hasToken) { setPendingDeployAppId(appId); setShowTokenDialog(true); setIsDeploying(false); return; } - const result = await publishApp(assistantId, appId); + const { data: result } = await appsByIdPublishPost({ + path: { assistant_id: assistantId, id: appId }, + throwOnError: true, + }); if (!result.success) { if (isCredentialError(result)) { setPendingDeployAppId(appId); @@ -438,7 +412,10 @@ export function LibraryView({ if (!appId) return; setIsDeploying(true); try { - const result = await publishApp(assistantId, appId); + const { data: result } = await appsByIdPublishPost({ + path: { assistant_id: assistantId, id: appId }, + throwOnError: true, + }); if (!result.success) { toast.error("Failed to deploy", { description: result.error }); } else if (result.publicUrl) { @@ -468,8 +445,11 @@ 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, + }); + void queryClient.invalidateQueries({ queryKey: appsGetQueryKey({ path: { assistant_id: assistantId } }) }); if (pinnedAppIds.has(target.id)) { togglePin(target); } @@ -489,11 +469,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); diff --git a/apps/web/src/components/vercel-token-dialog.tsx b/apps/web/src/components/vercel-token-dialog.tsx index 6737ebaa195..5d1c95cd1c1 100644 --- a/apps/web/src/components/vercel-token-dialog.tsx +++ b/apps/web/src/components/vercel-token-dialog.tsx @@ -2,7 +2,7 @@ import { Loader2 } from "lucide-react"; import { useCallback, useState } from "react"; import { Button, Input, Modal, toast, Typography } from "@vellum/design-library"; -import { setVercelToken } from "@/lib/publish-api"; +import { integrationsVercelConfigPost } from "@/generated/daemon/sdk.gen"; export interface VercelTokenDialogProps { open: boolean; @@ -28,7 +28,11 @@ export function VercelTokenDialog({ setError(null); try { - await setVercelToken(assistantId, token.trim()); + await integrationsVercelConfigPost({ + path: { assistant_id: assistantId }, + body: { action: "set", apiToken: token.trim() }, + throwOnError: true, + }); setToken(""); onTokenSaved(); } catch (err) { diff --git a/apps/web/src/domains/chat/components/conversation-assets-pill.tsx b/apps/web/src/domains/chat/components/conversation-assets-pill.tsx index a14f6053eba..bcc995ba4bb 100644 --- a/apps/web/src/domains/chat/components/conversation-assets-pill.tsx +++ b/apps/web/src/domains/chat/components/conversation-assets-pill.tsx @@ -1,5 +1,6 @@ import { AppWindow, FileText, Layers } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { BottomSheet, @@ -9,12 +10,15 @@ import { Typography, } from "@vellum/design-library"; -import { useIsMobile } from "@/hooks/use-is-mobile"; -import { listApps, type AppSummary } from "@/lib/apps-api"; import { - listDocuments, - type DocumentSummary, -} from "@/lib/documents-api"; + appsGetOptions, + appsGetQueryKey, + documentsGetOptions, + documentsGetQueryKey, +} from "@/generated/daemon/@tanstack/react-query.gen"; +import { useIsMobile } from "@/hooks/use-is-mobile"; +import type { AppSummary } from "@/types/app-types"; +import type { DocumentSummary } from "@/types/document-types"; interface ConversationAsset { id: string; @@ -57,11 +61,6 @@ function toAssets( return assets; } -interface AssetsState { - conversationId: string; - assets: ConversationAsset[]; -} - export function ConversationAssetsPill({ assistantId, conversationId, @@ -69,38 +68,39 @@ export function ConversationAssetsPill({ onOpenApp, onOpenDocument, }: ConversationAssetsPillProps) { - const [state, setState] = useState({ - conversationId, - assets: [], + const queryClient = useQueryClient(); + const appsQueryOpts = appsGetOptions({ + path: { assistant_id: assistantId }, + query: { conversationId }, + }); + const docsQueryOpts = documentsGetOptions({ + path: { assistant_id: assistantId }, + query: { conversationId }, }); - const [open, setOpen] = useState(false); - const isMobile = useIsMobile(); - const assets = - state.conversationId === conversationId ? state.assets : []; + const { data: apps = [] } = useQuery({ + ...appsQueryOpts, + select: (data) => data.apps, + }); + const { data: docs = [] } = useQuery({ + ...docsQueryOpts, + select: (data) => data.documents, + }); useEffect(() => { - let cancelled = false; - - async function fetchAssets() { - try { - const [apps, docs] = await Promise.all([ - listApps(assistantId, conversationId), - listDocuments(assistantId, conversationId), - ]); - if (!cancelled) { - setState({ conversationId, assets: toAssets(apps, docs) }); - } - } catch { - // Best-effort — don't break the UI if the endpoints aren't available - } - } + if (refreshKey === undefined) return; + void queryClient.invalidateQueries({ + queryKey: appsGetQueryKey({ path: { assistant_id: assistantId }, query: { conversationId } }), + }); + void queryClient.invalidateQueries({ + queryKey: documentsGetQueryKey({ path: { assistant_id: assistantId }, query: { conversationId } }), + }); + }, [refreshKey, queryClient, assistantId, conversationId]); + + const assets = useMemo(() => toAssets(apps, docs), [apps, docs]); - fetchAssets(); - return () => { - cancelled = true; - }; - }, [assistantId, conversationId, refreshKey]); + const [open, setOpen] = useState(false); + const isMobile = useIsMobile(); const handleSelect = useCallback( (asset: ConversationAsset) => { diff --git a/apps/web/src/domains/chat/components/document-viewer-container.tsx b/apps/web/src/domains/chat/components/document-viewer-container.tsx index 13418fd1832..c4989456013 100644 --- a/apps/web/src/domains/chat/components/document-viewer-container.tsx +++ b/apps/web/src/domains/chat/components/document-viewer-container.tsx @@ -29,7 +29,7 @@ import { Button, Typography } from "@vellum/design-library"; import type { DocumentComment } from "@/domains/chat/api/document-comments"; import { createComment, fetchComments } from "@/domains/chat/api/document-comments"; -import { saveDocumentContent } from "@/lib/documents-api"; +import { documentsPost } from "@/generated/daemon/sdk.gen"; import type { CommentAnchor } from "@/domains/chat/utils/tiptap-position-map"; import { DocumentCommentPanel, @@ -121,7 +121,12 @@ export function DocumentViewerContainer({ if (savedFadeRef.current) clearTimeout(savedFadeRef.current); setSaveStatus("saving"); saveTimerRef.current = setTimeout(() => { - void saveDocumentContent(assistantId, surfaceId, conversationId, documentName, markdown).then( + const wordCount = markdown.trim().split(/\s+/).filter((w) => w.length > 0).length; + void documentsPost({ + path: { assistant_id: assistantId }, + body: { surfaceId, conversationId, title: documentName, content: markdown, wordCount }, + throwOnError: true, + }).then( () => { setSaveStatus("saved"); savedFadeRef.current = setTimeout(() => setSaveStatus("idle"), 2000); diff --git a/apps/web/src/domains/chat/components/surfaces/dynamic-page-surface.test.tsx b/apps/web/src/domains/chat/components/surfaces/dynamic-page-surface.test.tsx index 49ef80ca300..6c62e1a8ecc 100644 --- a/apps/web/src/domains/chat/components/surfaces/dynamic-page-surface.test.tsx +++ b/apps/web/src/domains/chat/components/surfaces/dynamic-page-surface.test.tsx @@ -3,8 +3,9 @@ import { renderToStaticMarkup } from "react-dom/server"; import type { Surface } from "@/domains/chat/types/types"; -mock.module("@/lib/apps-api", () => ({ +mock.module("@/utils/app-html-cache", () => ({ getCachedAppHtml: () => Promise.resolve(""), + clearAppHtmlCache: () => {}, })); mock.module("@/stores/pinned-apps-store", () => { diff --git a/apps/web/src/domains/chat/components/surfaces/dynamic-page-surface.tsx b/apps/web/src/domains/chat/components/surfaces/dynamic-page-surface.tsx index 248bddd1961..0dc576f4a90 100644 --- a/apps/web/src/domains/chat/components/surfaces/dynamic-page-surface.tsx +++ b/apps/web/src/domains/chat/components/surfaces/dynamic-page-surface.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { client } from "@/generated/api/client.gen"; import { AppCard } from "@/components/app-card"; -import { clearAppHtmlCache, getCachedAppHtml } from "@/lib/apps-api"; +import { clearAppHtmlCache, getCachedAppHtml } from "@/utils/app-html-cache"; import { usePinnedAppsStore } from "@/stores/pinned-apps-store"; import type { Surface } from "@/domains/chat/types/types"; import { getDynamicPageAppId } from "@/domains/chat/components/surfaces/dynamic-page-app-id"; diff --git a/apps/web/src/domains/chat/deploy-store.ts b/apps/web/src/domains/chat/deploy-store.ts index ecda68250ad..04918cf1b02 100644 --- a/apps/web/src/domains/chat/deploy-store.ts +++ b/apps/web/src/domains/chat/deploy-store.ts @@ -18,9 +18,13 @@ import { create } from "zustand"; import { toast } from "@vellum/design-library"; -import { shareApp as shareAppApi } from "@/lib/apps-api"; -import { getVercelConfig, isCredentialError, publishApp } from "@/lib/publish-api"; +import { + appsByIdPublishPost, + integrationsVercelConfigGet, +} from "@/generated/daemon/sdk.gen"; +import { isCredentialError } from "@/types/publish-types"; import { createSelectors } from "@/utils/create-selectors"; +import { shareApp as shareAppApi } from "@/utils/share-app"; // --------------------------------------------------------------------------- // Types @@ -125,12 +129,18 @@ const useDeployStoreBase = create()((set, get) => ({ } set({ isDeploying: true }); try { - const config = await getVercelConfig(assistantId); + const { data: config } = await integrationsVercelConfigGet({ + path: { assistant_id: assistantId }, + throwOnError: true, + }); if (!config.hasToken) { set({ isTokenDialogOpen: true, pendingDeployAppId: appId, isDeploying: false }); return; } - const result = await publishApp(assistantId, appId); + const { data: result } = await appsByIdPublishPost({ + path: { assistant_id: assistantId, id: appId }, + throwOnError: true, + }); if (!result.success) { if (isCredentialError(result)) { set({ isTokenDialogOpen: true, pendingDeployAppId: appId, isDeploying: false }); @@ -163,7 +173,10 @@ const useDeployStoreBase = create()((set, get) => ({ if (!pendingDeployAppId) return; set({ isDeploying: true }); try { - const result = await publishApp(assistantId, pendingDeployAppId); + const { data: result } = await appsByIdPublishPost({ + path: { assistant_id: assistantId, id: pendingDeployAppId }, + throwOnError: true, + }); if (!result.success) { toast.error("Failed to deploy", { description: result.error }); } else if (result.publicUrl) { diff --git a/apps/web/src/domains/chat/document-viewer-page.tsx b/apps/web/src/domains/chat/document-viewer-page.tsx index 9f5401e5de2..4d5e451357d 100644 --- a/apps/web/src/domains/chat/document-viewer-page.tsx +++ b/apps/web/src/domains/chat/document-viewer-page.tsx @@ -17,11 +17,11 @@ import { getEditChatConversationId, setEditChatConversationId } from "@/domains/ import { useViewerStore } from "@/stores/viewer-store"; import { routes } from "@/utils/routes"; import { - type DocumentContent, - exportDocumentPDF, - fetchDocumentContent, - linkDocumentConversation, -} from "@/lib/documents-api"; + documentsByIdConversationsPost, + documentsByIdGet, + documentsByIdPdfGet, +} from "@/generated/daemon/sdk.gen"; +import type { DocumentContent } from "@/types/document-types"; import { useDocumentCommentEvents } from "./hooks/use-document-comment-events"; import { useBusSubscription } from "@/hooks/use-bus-subscription"; import { @@ -54,16 +54,12 @@ export function DocumentViewerPage() { let cancelled = false; void (async () => { try { - const result = await fetchDocumentContent( - assistantId, - surfaceId, - ); + const { data: result } = await documentsByIdGet({ + path: { assistant_id: assistantId, id: surfaceId }, + throwOnError: true, + }); if (cancelled) return; - if (!result) { - setError("Document not found."); - } else { - setDoc(result); - } + setDoc(result); } catch { if (!cancelled) { setError("Failed to load document."); @@ -120,7 +116,11 @@ export function DocumentViewerPage() { if (conversationId !== doc.conversationId) { try { - await linkDocumentConversation(assistantId, surfaceId, conversationId); + await documentsByIdConversationsPost({ + path: { assistant_id: assistantId, id: surfaceId }, + body: { conversationId }, + throwOnError: true, + }); } catch { // Best-effort — fails if the daemon doesn't have the route yet. } @@ -140,8 +140,13 @@ export function DocumentViewerPage() { const handleExport = useCallback(async () => { if (!doc || !assistantId) return; - const blob = await exportDocumentPDF(assistantId, doc.surfaceId); - if (!blob) return; + const { response: pdfResponse } = await documentsByIdPdfGet({ + path: { assistant_id: assistantId, id: doc.surfaceId }, + throwOnError: false, + parseAs: "stream", + }); + if (!pdfResponse || !pdfResponse.ok) return; + const blob = await pdfResponse.blob(); const url = URL.createObjectURL(blob); const a = Object.assign(document.createElement("a"), { href: url, diff --git a/apps/web/src/domains/library/library-detail-page.tsx b/apps/web/src/domains/library/library-detail-page.tsx index 86de93bda9d..63dd0ada579 100644 --- a/apps/web/src/domains/library/library-detail-page.tsx +++ b/apps/web/src/domains/library/library-detail-page.tsx @@ -5,8 +5,10 @@ import { useNavigate, useParams } from "react-router"; import { toast } from "@vellum/design-library"; import { useActiveAssistantContext } from "@/components/layout/active-assistant-gate"; -import { openApp, primeAppHtmlCache, shareApp } from "@/lib/apps-api"; +import { appsByIdOpenPost } from "@/generated/daemon/sdk.gen"; import { AppViewerContainer } from "@/components/apps/app-viewer-container"; +import { primeAppHtmlCache } from "@/utils/app-html-cache"; +import { shareApp } from "@/utils/share-app"; import { routes } from "@/utils/routes"; interface LoadedApp { @@ -32,8 +34,11 @@ export function LibraryDetailPage() { setApp(null); setError(null); - openApp(assistantId, appId) - .then((result) => { + appsByIdOpenPost({ + path: { assistant_id: assistantId, id: appId }, + throwOnError: true, + }) + .then(({ data: result }) => { if (requestRef.current !== appId) return; primeAppHtmlCache(assistantId, result.appId, result.html); setApp({ diff --git a/apps/web/src/lib/apps-api.ts b/apps/web/src/lib/apps-api.ts deleted file mode 100644 index b8d790d1880..00000000000 --- a/apps/web/src/lib/apps-api.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * App CRUD and bundle operations via the generated daemon SDK. - * - * Types are re-exported from the generated SDK so consumers don't need - * to reach into `@/generated/daemon/` directly. - */ - -import { client as daemonClient } from "@/generated/daemon/client.gen"; -import { - appsByIdDeletePost, - appsByIdOpenPost, - appsByIdSharecloudPost, - appsGet, - appsSharedByTokenGet, -} from "@/generated/daemon/sdk.gen"; -import type { - AppsByIdOpenPostResponse, - AppsGetResponse, - AppsImportbundlePostResponse, - AppsImportbundlePostResponses, -} from "@/generated/daemon/types.gen"; -import { ApiError, assertHasResponse, extractErrorMessage } from "@/lib/api-errors"; -import { saveFile } from "@/runtime/native-file"; - -// --------------------------------------------------------------------------- -// Types — re-exported from generated daemon SDK -// --------------------------------------------------------------------------- - -export type AppSummary = AppsGetResponse["apps"][number]; - -export type AppOpenResponse = AppsByIdOpenPostResponse; - -export type ImportBundleResponse = AppsImportbundlePostResponse; - -// --------------------------------------------------------------------------- -// API functions -// --------------------------------------------------------------------------- - -export async function listApps( - assistantId: string, - conversationId?: string, -): Promise { - const { data, error, response } = await appsGet({ - path: { assistant_id: assistantId }, - query: conversationId ? { conversationId } : undefined, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to list apps."); - if (!response.ok) { - const msg = extractErrorMessage(error, response, "Failed to list apps."); - throw new ApiError(response.status, msg); - } - return data?.apps ?? []; -} - -export async function deleteApp( - assistantId: string, - appId: string, -): Promise { - const { error, response } = await appsByIdDeletePost({ - path: { assistant_id: assistantId, id: appId }, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to delete app."); - if (!response.ok) { - const msg = extractErrorMessage(error, response, "Failed to delete app."); - throw new ApiError(response.status, msg); - } - clearAppHtmlCache(assistantId, appId); -} - -/** - * Share an app as a downloadable `.vellum` bundle. - * - * 1. Calls the share-cloud endpoint to package the app server-side. - * 2. Downloads the binary bundle using the returned share token. - * 3. Saves/shares the file via the cross-platform saveFile helper. - */ -export async function shareApp( - assistantId: string, - appId: string, - appName: string, -): Promise { - const { data, error, response } = await appsByIdSharecloudPost({ - path: { assistant_id: assistantId, id: appId }, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to share app."); - if (!response.ok) { - const msg = extractErrorMessage(error, response, "Failed to share app."); - throw new ApiError(response.status, msg); - } - if (!data?.shareToken) { - throw new ApiError(500, "Share response missing token."); - } - - const { response: dlResponse } = await appsSharedByTokenGet({ - path: { assistant_id: assistantId, token: data.shareToken }, - throwOnError: false, - parseAs: "stream", - }); - if (!dlResponse || !dlResponse.ok) { - throw new ApiError(dlResponse?.status ?? 500, "Failed to download app bundle."); - } - const blob = await dlResponse.blob(); - - const safeName = appName.replace(/[/\\:*?"<>|]/g, "_").trim() || "App"; - await saveFile(blob, `${safeName}.vellum`); -} - -/** - * Import a `.vellum` bundle file into the assistant daemon. - * - * Sends the raw file bytes as `application/octet-stream`. We use - * octet-stream (not multipart) because the Django wildcard proxy only - * forwards `application/octet-stream` as raw binary — multipart is - * parsed by DRF which drops the file from the forwarded body. - */ -export async function importBundle( - assistantId: string, - file: File, -): Promise { - const bytes = await file.arrayBuffer(); - // The daemon route definition doesn't declare a requestBody, so the - // generated SDK types have `body?: never`. Use the raw daemon client - // for this binary upload. - const { data, error, response } = await daemonClient.post< - AppsImportbundlePostResponses, - unknown - >({ - url: "/v1/assistants/{assistant_id}/apps/import-bundle", - path: { assistant_id: assistantId }, - headers: { "Content-Type": "application/octet-stream" }, - body: bytes, - bodySerializer: (body) => body as ArrayBuffer, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to import app."); - if (!response.ok) { - const msg = extractErrorMessage(error, response, "Failed to import app."); - throw new ApiError(response.status, msg); - } - return data!; -} - -export async function openApp( - assistantId: string, - appId: string, -): Promise { - const { data, error, response } = await appsByIdOpenPost({ - path: { assistant_id: assistantId, id: appId }, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to open app."); - if (!response.ok) { - const msg = extractErrorMessage(error, response, "Failed to open app."); - throw new ApiError(response.status, msg); - } - return data!; -} - -// --------------------------------------------------------------------------- -// In-memory HTML cache for preview thumbnails + viewer. -// -// The daemon's `apps/:id/open` is idempotent for already-built apps (returns -// the disk-cached HTML) and auto-compiles once for multi-file apps that have -// not been built yet. Caching the result here means a Library scroll triggers -// at most one fetch per app, and opening the viewer afterwards is free. -// --------------------------------------------------------------------------- - -const htmlCache = new Map>(); - -function cacheKey(assistantId: string, appId: string): string { - return `${assistantId}::${appId}`; -} - -export function getCachedAppHtml( - assistantId: string, - appId: string, -): Promise { - const key = cacheKey(assistantId, appId); - let entry = htmlCache.get(key); - if (entry == null) { - entry = openApp(assistantId, appId) - .then((r) => r.html) - .catch((err) => { - htmlCache.delete(key); - throw err; - }); - htmlCache.set(key, entry); - } - return entry; -} - -export function primeAppHtmlCache( - assistantId: string, - appId: string, - html: string, -): void { - htmlCache.set(cacheKey(assistantId, appId), Promise.resolve(html)); -} - -export function clearAppHtmlCache(assistantId: string, appId: string): void { - htmlCache.delete(cacheKey(assistantId, appId)); -} diff --git a/apps/web/src/lib/documents-api.ts b/apps/web/src/lib/documents-api.ts deleted file mode 100644 index 1a67454dacc..00000000000 --- a/apps/web/src/lib/documents-api.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Document CRUD operations via the generated daemon SDK. - * - * Wraps the auto-generated daemon client functions with app-specific - * error handling. Types are re-exported from the generated SDK. - */ - -import { - documentsByIdConversationsPost, - documentsByIdGet, - documentsByIdPdfGet, - documentsGet, - documentsPost, -} from "@/generated/daemon/sdk.gen"; -import type { - DocumentsByIdGetResponse, - DocumentsGetResponse, -} from "@/generated/daemon/types.gen"; -import { ApiError, assertHasResponse, extractErrorMessage } from "@/lib/api-errors"; - -// --------------------------------------------------------------------------- -// Types — re-exported from generated daemon SDK -// --------------------------------------------------------------------------- - -export type DocumentSummary = DocumentsGetResponse["documents"][number]; - -export type DocumentContent = DocumentsByIdGetResponse; - -// --------------------------------------------------------------------------- -// API functions -// --------------------------------------------------------------------------- - -export async function fetchDocumentContent( - assistantId: string, - documentSurfaceId: string, -): Promise { - try { - const { data, error, response } = await documentsByIdGet({ - path: { assistant_id: assistantId, id: documentSurfaceId }, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to fetch document."); - if (!response.ok || !data) { - return null; - } - return data; - } catch { - return null; - } -} - -export async function exportDocumentPDF( - assistantId: string, - documentSurfaceId: string, -): Promise { - try { - const { response } = await documentsByIdPdfGet({ - path: { assistant_id: assistantId, id: documentSurfaceId }, - throwOnError: false, - parseAs: "stream", - }); - if (!response || !response.ok) { - return null; - } - return response.blob(); - } catch { - return null; - } -} - -export async function listDocuments( - assistantId: string, - conversationId?: string, -): Promise { - const { data, error, response } = await documentsGet({ - path: { assistant_id: assistantId }, - query: conversationId ? { conversationId } : undefined, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to list documents."); - if (!response.ok) { - const msg = extractErrorMessage( - error, - response, - "Failed to list documents.", - ); - throw new ApiError(response.status, msg); - } - return data?.documents ?? []; -} - -export async function saveDocumentContent( - assistantId: string, - surfaceId: string, - conversationId: string, - title: string, - content: string, -): Promise { - const wordCount = content.trim().split(/\s+/).filter((w) => w.length > 0).length; - const { error, response } = await documentsPost({ - path: { assistant_id: assistantId }, - body: { surfaceId, conversationId, title, content, wordCount }, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to save document."); - if (!response.ok) { - const msg = extractErrorMessage(error, response, "Failed to save document."); - throw new ApiError(response.status, msg); - } -} - -export async function linkDocumentConversation( - assistantId: string, - documentSurfaceId: string, - conversationId: string, -): Promise { - const { error, response } = await documentsByIdConversationsPost({ - path: { assistant_id: assistantId, id: documentSurfaceId }, - body: { conversationId }, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to link document to conversation."); - if (!response.ok) { - const msg = extractErrorMessage( - error, - response, - "Failed to link document to conversation.", - ); - throw new ApiError(response.status, msg); - } -} diff --git a/apps/web/src/lib/publish-api.ts b/apps/web/src/lib/publish-api.ts deleted file mode 100644 index 40be80e1184..00000000000 --- a/apps/web/src/lib/publish-api.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Vercel publish / unpublish operations via the generated daemon SDK. - * - * Types are re-exported from the generated SDK so consumers don't need - * to reach into `@/generated/daemon/` directly. - */ - -import { - appsByIdPublishPost, - appsByIdPublishstatusGet, - appsByIdUnpublishPost, - integrationsVercelConfigGet, - integrationsVercelConfigPost, -} from "@/generated/daemon/sdk.gen"; -import type { - AppsByIdPublishPostResponse, - AppsByIdPublishstatusGetResponse, - AppsByIdUnpublishPostResponse, - IntegrationsVercelConfigGetResponse, -} from "@/generated/daemon/types.gen"; -import { ApiError, assertHasResponse, extractErrorMessage } from "@/lib/api-errors"; - -// --------------------------------------------------------------------------- -// Types — re-exported from generated daemon SDK -// --------------------------------------------------------------------------- - -export type VercelConfigResponse = IntegrationsVercelConfigGetResponse; - -export type PublishPageResponse = AppsByIdPublishPostResponse; - -export type UnpublishPageResponse = AppsByIdUnpublishPostResponse; - -export type PublishStatusResponse = AppsByIdPublishstatusGetResponse; - -export function isCredentialError(result: PublishPageResponse): boolean { - return ( - result.errorCode === "credentials_missing" || - !!result.error?.includes("not allowed to use credential") || - !!result.error?.includes("domain restrictions") || - !!result.error?.includes("Credential use failed") - ); -} - -// --------------------------------------------------------------------------- -// API functions -// --------------------------------------------------------------------------- - -export async function getVercelConfig( - assistantId: string, -): Promise { - const { data, error, response } = await integrationsVercelConfigGet({ - path: { assistant_id: assistantId }, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to get Vercel config."); - if (!response.ok) { - const msg = extractErrorMessage(error, response, "Failed to get Vercel config."); - throw new ApiError(response.status, msg); - } - return data!; -} - -export async function setVercelToken( - assistantId: string, - apiToken: string, -): Promise { - const { error, response } = await integrationsVercelConfigPost({ - path: { assistant_id: assistantId }, - body: { action: "set", apiToken }, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to set Vercel token."); - if (!response.ok) { - const msg = extractErrorMessage(error, response, "Failed to set Vercel token."); - throw new ApiError(response.status, msg); - } -} - -export async function publishApp( - assistantId: string, - appId: string, -): Promise { - const { data, error, response } = await appsByIdPublishPost({ - path: { assistant_id: assistantId, id: appId }, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to publish app."); - if (!response.ok) { - const msg = extractErrorMessage(error, response, "Failed to publish app."); - throw new ApiError(response.status, msg); - } - const result = { ...data! }; - - if (result.success && !result.publicUrl) { - try { - const status = await getPublishStatus(assistantId, appId); - if (status.publicUrl) { - result.publicUrl = status.publicUrl; - } - if (status.deploymentId && !result.deploymentId) { - result.deploymentId = status.deploymentId; - } - } catch { - // Best-effort — still return the publish result even if status lookup fails - } - } - - return result; -} - -export async function unpublishApp( - assistantId: string, - appId: string, -): Promise { - const { data, error, response } = await appsByIdUnpublishPost({ - path: { assistant_id: assistantId, id: appId }, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to unpublish app."); - if (!response.ok) { - const msg = extractErrorMessage(error, response, "Failed to unpublish app."); - throw new ApiError(response.status, msg); - } - return data!; -} - -export async function getPublishStatus( - assistantId: string, - appId: string, -): Promise { - const { data, error, response } = await appsByIdPublishstatusGet({ - path: { assistant_id: assistantId, id: appId }, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to get publish status."); - if (!response.ok) { - const msg = extractErrorMessage(error, response, "Failed to get publish status."); - throw new ApiError(response.status, msg); - } - return data!; -} diff --git a/apps/web/src/stores/pinned-apps-store.ts b/apps/web/src/stores/pinned-apps-store.ts index 45a8d70ad88..f24a9bd6b84 100644 --- a/apps/web/src/stores/pinned-apps-store.ts +++ b/apps/web/src/stores/pinned-apps-store.ts @@ -12,7 +12,7 @@ import { create } from "zustand"; import { createSelectors } from "@/utils/create-selectors"; -import type { AppSummary } from "@/lib/apps-api"; +import type { AppSummary } from "@/types/app-types"; import { loadPinnedApps, pinApp, diff --git a/apps/web/src/stores/viewer-store.ts b/apps/web/src/stores/viewer-store.ts index db6d748d430..1d9959f3c7f 100644 --- a/apps/web/src/stores/viewer-store.ts +++ b/apps/web/src/stores/viewer-store.ts @@ -22,8 +22,8 @@ import * as Sentry from "@sentry/react"; import { create } from "zustand"; -import { openApp, primeAppHtmlCache } from "@/lib/apps-api"; -import { fetchDocumentContent } from "@/lib/documents-api"; +import { appsByIdOpenPost, documentsByIdGet } from "@/generated/daemon/sdk.gen"; +import { primeAppHtmlCache } from "@/utils/app-html-cache"; import { createSelectors } from "@/utils/create-selectors"; // --------------------------------------------------------------------------- @@ -159,7 +159,10 @@ const useViewerStoreBase = create()((set, get) => ({ isAppMinimized: false, }); try { - const result = await openApp(assistantId, appId); + const { data: result } = await appsByIdOpenPost({ + path: { assistant_id: assistantId, id: appId }, + throwOnError: true, + }); if (get().activeAppId !== appId) return; const app = { appId: result.appId, dirName: result.dirName, name: result.name, html: result.html }; set({ openedAppState: app }); @@ -268,7 +271,10 @@ const useViewerStoreBase = create()((set, get) => ({ viewBeforeDocument, }); try { - const result = await fetchDocumentContent(assistantId, documentSurfaceId); + const { data: result } = await documentsByIdGet({ + path: { assistant_id: assistantId, id: documentSurfaceId }, + throwOnError: true, + }); if (get().activeDocumentSurfaceId !== documentSurfaceId) return; if (!result) { set({ mainView: viewBeforeDocument, activeDocumentSurfaceId: null, openedDocumentState: null }); diff --git a/apps/web/src/types/app-types.ts b/apps/web/src/types/app-types.ts new file mode 100644 index 00000000000..c8e5345f253 --- /dev/null +++ b/apps/web/src/types/app-types.ts @@ -0,0 +1,11 @@ +import type { + AppsByIdOpenPostResponse, + AppsGetResponse, + AppsImportbundlePostResponse, +} from "@/generated/daemon/types.gen"; + +export type AppSummary = AppsGetResponse["apps"][number]; + +export type AppOpenResponse = AppsByIdOpenPostResponse; + +export type ImportBundleResponse = AppsImportbundlePostResponse; diff --git a/apps/web/src/types/document-types.ts b/apps/web/src/types/document-types.ts new file mode 100644 index 00000000000..3d7182feaf7 --- /dev/null +++ b/apps/web/src/types/document-types.ts @@ -0,0 +1,8 @@ +import type { + DocumentsByIdGetResponse, + DocumentsGetResponse, +} from "@/generated/daemon/types.gen"; + +export type DocumentSummary = DocumentsGetResponse["documents"][number]; + +export type DocumentContent = DocumentsByIdGetResponse; diff --git a/apps/web/src/types/publish-types.ts b/apps/web/src/types/publish-types.ts new file mode 100644 index 00000000000..2a6f6d92610 --- /dev/null +++ b/apps/web/src/types/publish-types.ts @@ -0,0 +1,23 @@ +import type { + AppsByIdPublishPostResponse, + AppsByIdPublishstatusGetResponse, + AppsByIdUnpublishPostResponse, + IntegrationsVercelConfigGetResponse, +} from "@/generated/daemon/types.gen"; + +export type VercelConfigResponse = IntegrationsVercelConfigGetResponse; + +export type PublishPageResponse = AppsByIdPublishPostResponse; + +export type UnpublishPageResponse = AppsByIdUnpublishPostResponse; + +export type PublishStatusResponse = AppsByIdPublishstatusGetResponse; + +export function isCredentialError(result: PublishPageResponse): boolean { + return ( + result.errorCode === "credentials_missing" || + !!result.error?.includes("not allowed to use credential") || + !!result.error?.includes("domain restrictions") || + !!result.error?.includes("Credential use failed") + ); +} diff --git a/apps/web/src/utils/app-html-cache.ts b/apps/web/src/utils/app-html-cache.ts new file mode 100644 index 00000000000..ed4cea00b89 --- /dev/null +++ b/apps/web/src/utils/app-html-cache.ts @@ -0,0 +1,50 @@ +/** + * In-memory HTML cache for app preview thumbnails and the viewer. + * + * The daemon's `apps/:id/open` is idempotent for already-built apps + * (returns the disk-cached HTML) and auto-compiles once for multi-file + * apps that have not been built yet. Caching here means a Library scroll + * triggers at most one fetch per app, and opening the viewer afterwards + * is free. + */ + +import { appsByIdOpenPost } from "@/generated/daemon/sdk.gen"; + +const htmlCache = new Map>(); + +function cacheKey(assistantId: string, appId: string): string { + return `${assistantId}::${appId}`; +} + +export function getCachedAppHtml( + assistantId: string, + appId: string, +): Promise { + const key = cacheKey(assistantId, appId); + let entry = htmlCache.get(key); + if (entry == null) { + entry = appsByIdOpenPost({ + path: { assistant_id: assistantId, id: appId }, + throwOnError: true, + }) + .then((r) => r.data.html) + .catch((err) => { + htmlCache.delete(key); + throw err; + }); + htmlCache.set(key, entry); + } + return entry; +} + +export function primeAppHtmlCache( + assistantId: string, + appId: string, + html: string, +): void { + htmlCache.set(cacheKey(assistantId, appId), Promise.resolve(html)); +} + +export function clearAppHtmlCache(assistantId: string, appId: string): void { + htmlCache.delete(cacheKey(assistantId, appId)); +} diff --git a/apps/web/src/utils/app-pin-storage.test.ts b/apps/web/src/utils/app-pin-storage.test.ts index b3e388355a6..b347ef2416e 100644 --- a/apps/web/src/utils/app-pin-storage.test.ts +++ b/apps/web/src/utils/app-pin-storage.test.ts @@ -1,6 +1,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import type { AppSummary } from "@/lib/apps-api"; +import type { AppSummary } from "@/types/app-types"; import { isAppPinned, loadPinnedApps, diff --git a/apps/web/src/utils/app-pin-storage.ts b/apps/web/src/utils/app-pin-storage.ts index e07b81509f0..941909007cc 100644 --- a/apps/web/src/utils/app-pin-storage.ts +++ b/apps/web/src/utils/app-pin-storage.ts @@ -4,7 +4,7 @@ // // Shape on disk: PinnedAppEntry[] -import type { AppSummary } from "@/lib/apps-api"; +import type { AppSummary } from "@/types/app-types"; const STORAGE_KEY = "vellum:pinnedApps"; diff --git a/apps/web/src/utils/import-bundle.ts b/apps/web/src/utils/import-bundle.ts new file mode 100644 index 00000000000..96e45a8857e --- /dev/null +++ b/apps/web/src/utils/import-bundle.ts @@ -0,0 +1,42 @@ +/** + * Import a `.vellum` bundle file into the assistant daemon. + * + * Sends the raw file bytes as `application/octet-stream`. We use + * octet-stream (not multipart) because the Django wildcard proxy only + * forwards `application/octet-stream` as raw binary — multipart is + * parsed by DRF which drops the file from the forwarded body. + * + * The daemon route definition doesn't declare a requestBody, so the + * generated SDK types have `body?: never`. Uses the raw daemon client + * for this binary upload. + */ + +import { client as daemonClient } from "@/generated/daemon/client.gen"; +import type { AppsImportbundlePostResponses } from "@/generated/daemon/types.gen"; +import type { ImportBundleResponse } from "@/types/app-types"; + +export async function importBundle( + assistantId: string, + file: File, +): Promise { + const bytes = await file.arrayBuffer(); + const { data, error, response } = await daemonClient.post< + AppsImportbundlePostResponses, + unknown + >({ + url: "/v1/assistants/{assistant_id}/apps/import-bundle", + path: { assistant_id: assistantId }, + headers: { "Content-Type": "application/octet-stream" }, + body: bytes, + bodySerializer: (body) => body as ArrayBuffer, + throwOnError: false, + }); + if (!response || !response.ok) { + const msg = + (error && typeof error === "object" && "message" in error + ? (error as { message: string }).message + : null) ?? "Failed to import app."; + throw new Error(msg); + } + return data!; +} diff --git a/apps/web/src/utils/share-app.ts b/apps/web/src/utils/share-app.ts new file mode 100644 index 00000000000..22f46e64e06 --- /dev/null +++ b/apps/web/src/utils/share-app.ts @@ -0,0 +1,40 @@ +/** + * Export an app as a downloadable `.vellum` bundle. + * + * 1. Calls the share-cloud endpoint to package the app server-side. + * 2. Downloads the binary bundle using the returned share token. + * 3. Saves/shares the file via the cross-platform saveFile helper. + */ + +import { + appsByIdSharecloudPost, + appsSharedByTokenGet, +} from "@/generated/daemon/sdk.gen"; +import { saveFile } from "@/runtime/native-file"; + +export async function shareApp( + assistantId: string, + appId: string, + appName: string, +): Promise { + const { data } = await appsByIdSharecloudPost({ + path: { assistant_id: assistantId, id: appId }, + throwOnError: true, + }); + if (!data.shareToken) { + throw new Error("Share response missing token."); + } + + const { response: dlResponse } = await appsSharedByTokenGet({ + path: { assistant_id: assistantId, token: data.shareToken }, + throwOnError: false, + parseAs: "stream", + }); + if (!dlResponse || !dlResponse.ok) { + throw new Error("Failed to download app bundle."); + } + const blob = await dlResponse.blob(); + + const safeName = appName.replace(/[/\\:*?"<>|]/g, "_").trim() || "App"; + await saveFile(blob, `${safeName}.vellum`); +} From 143c8ca9893c34f0948ce9701bfd44562089f232 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 19:22:32 +0000 Subject: [PATCH 2/2] refactor(web): DRY up deploy flow, restore publish-status fallback, extract sub-components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create utils/publish-app.ts: shared publishApp() with best-effort status enrichment when publicUrl is missing from publish response (restores behavior dropped from lib/publish-api.ts) - Move deploy-store.ts from domains/chat/ to stores/ (cross-domain shared state used by both chat-page and library-view) - Consolidate library-view.tsx to use deploy store instead of 4 local useState hooks for deploy state (isDeploying, tokenDialog, etc.) - Extract LibraryAppCard, LibraryDocumentCard, LibraryAppCardActionsMenu into dedicated component files (library-view 916→500 lines) - Fix clearAppHtmlCache not called after app deletion (stale cache bug) - DRY up publish result toast (5 identical blocks → showPublishResultToast) Co-Authored-By: ashlee@vellum.ai --- .../src/components/apps/library-app-card.tsx | 285 +++++++++++ apps/web/src/components/apps/library-date.ts | 8 + .../components/apps/library-document-card.tsx | 48 ++ apps/web/src/components/apps/library-view.tsx | 477 ++---------------- apps/web/src/domains/chat/chat-page.tsx | 2 +- .../chat/components/chat-route-content.tsx | 2 +- .../chat => stores}/deploy-store.test.ts | 2 +- .../{domains/chat => stores}/deploy-store.ts | 68 ++- apps/web/src/utils/publish-app.ts | 44 ++ 9 files changed, 456 insertions(+), 480 deletions(-) create mode 100644 apps/web/src/components/apps/library-app-card.tsx create mode 100644 apps/web/src/components/apps/library-date.ts create mode 100644 apps/web/src/components/apps/library-document-card.tsx rename apps/web/src/{domains/chat => stores}/deploy-store.test.ts (98%) rename apps/web/src/{domains/chat => stores}/deploy-store.ts (78%) create mode 100644 apps/web/src/utils/publish-app.ts 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 5232a41e331..e2345203720 100644 --- a/apps/web/src/components/apps/library-view.tsx +++ b/apps/web/src/components/apps/library-view.tsx @@ -1,23 +1,14 @@ import { - ArrowUp, - Ellipsis, - FileText, - Globe, LayoutGrid, - Pin, - PinOff, Search, - Trash2, Upload, } from "lucide-react"; -import { type ChangeEvent, type MouseEvent, useCallback, useMemo, useRef, useState } from "react"; +import { type ChangeEvent, useCallback, useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { appsByIdDeletePost, appsByIdOpenPost, - appsByIdPublishPost, - integrationsVercelConfigGet, } from "@/generated/daemon/sdk.gen"; import { appsGetOptions, @@ -25,207 +16,30 @@ import { documentsGetOptions, } from "@/generated/daemon/@tanstack/react-query.gen"; import type { AppSummary } from "@/types/app-types"; -import type { DocumentSummary } from "@/types/document-types"; -import { isCredentialError } from "@/types/publish-types"; -import { getCachedAppHtml, primeAppHtmlCache } from "@/utils/app-html-cache"; +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; } @@ -243,6 +57,11 @@ export function LibraryView({ const togglePin = usePinnedAppsStore.use.togglePin(); 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, @@ -264,18 +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); const filteredApps = useMemo(() => { if (!searchText.trim()) return apps; @@ -305,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; @@ -352,89 +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 { data: config } = await integrationsVercelConfigGet({ - path: { assistant_id: assistantId }, - throwOnError: true, - }); - if (!config.hasToken) { - setPendingDeployAppId(appId); - setShowTokenDialog(true); - setIsDeploying(false); - return; - } - const { data: result } = await appsByIdPublishPost({ - path: { assistant_id: assistantId, id: appId }, - throwOnError: true, - }); - 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 { data: result } = await appsByIdPublishPost({ - path: { assistant_id: assistantId, id: appId }, - throwOnError: true, - }); - 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], @@ -449,6 +187,7 @@ export function LibraryView({ 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); @@ -507,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)} /> ); @@ -727,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 ( - - -