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 (
+
+
+ }
+ aria-label="App actions"
+ onClick={(e: MouseEvent) => e.stopPropagation()}
+ />
+
+
+
+ {appName}
+
+
+ {
+ onOpenChange(false);
+ onPin();
+ }}
+ />
+ {onShare ? (
+
+ Share
+
+ Export as .vellum file
+
+
+ }
+ onSelect={() => {
+ onOpenChange(false);
+ onShare();
+ }}
+ />
+ ) : null}
+ {onDeploy ? (
+
+ Deploy to Vercel
+
+ Publish as a static page
+
+
+ }
+ onSelect={() => {
+ onOpenChange(false);
+ onDeploy();
+ }}
+ />
+ ) : null}
+ {onDelete ? (
+ {
+ onOpenChange(false);
+ onDelete();
+ }}
+ />
+ ) : null}
+
+
+
+ );
+ }
+ return (
+
+
+ }
+ aria-label="App actions"
+ onClick={(e: MouseEvent) => e.stopPropagation()}
+ />
+
+
+ : }
+ onSelect={() => onPin()}
+ className="whitespace-nowrap"
+ >
+ {isPinned ? "Unpin" : "Pin"}
+
+ {onShare ? (
+ }
+ onSelect={() => onShare()}
+ className="whitespace-nowrap"
+ >
+ Share
+
+ ) : null}
+ {onDeploy ? (
+ }
+ onSelect={() => onDeploy()}
+ className="whitespace-nowrap"
+ >
+ Deploy to Vercel
+
+ ) : null}
+ {onDelete ? (
+ }
+ onSelect={() => onDelete()}
+ className="whitespace-nowrap text-red-600 data-[highlighted]:text-red-700"
+ >
+ Delete
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/src/components/apps/library-date.ts b/apps/web/src/components/apps/library-date.ts
new file mode 100644
index 00000000000..03064e329cc
--- /dev/null
+++ b/apps/web/src/components/apps/library-date.ts
@@ -0,0 +1,8 @@
+export function formatLibraryDate(epochMs: number): string {
+ const date = new Date(epochMs);
+ return date.toLocaleDateString(undefined, {
+ day: "numeric",
+ month: "short",
+ year: date.getFullYear() !== new Date().getFullYear() ? "numeric" : undefined,
+ });
+}
diff --git a/apps/web/src/components/apps/library-document-card.tsx b/apps/web/src/components/apps/library-document-card.tsx
new file mode 100644
index 00000000000..760725dd751
--- /dev/null
+++ b/apps/web/src/components/apps/library-document-card.tsx
@@ -0,0 +1,48 @@
+import { FileText } from "lucide-react";
+
+import type { DocumentSummary } from "@/types/document-types";
+import { cn } from "@/utils/misc";
+import { formatLibraryDate } from "@/components/apps/library-date";
+
+function formatWordCount(count: number): string {
+ return count === 1 ? "1 word" : `${count} words`;
+}
+
+interface LibraryDocumentCardProps {
+ document: DocumentSummary;
+ onOpen: (documentSurfaceId: string) => void;
+}
+
+export function LibraryDocumentCard({ document, onOpen }: LibraryDocumentCardProps) {
+ 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 (
-
-
- }
- aria-label="App actions"
- onClick={(e: MouseEvent) => e.stopPropagation()}
- />
-
-
-
- {appName}
-
-
- {
- onOpenChange(false);
- onPin();
- }}
- />
- {onShare ? (
-
- Share
-
- Export as .vellum file
-
-
- }
- onSelect={() => {
- onOpenChange(false);
- onShare();
- }}
- />
- ) : null}
- {onDeploy ? (
-
- Deploy to Vercel
-
- Publish as a static page
-
-
- }
- onSelect={() => {
- onOpenChange(false);
- onDeploy();
- }}
- />
- ) : null}
- {onDelete ? (
- {
- onOpenChange(false);
- onDelete();
- }}
- />
- ) : null}
-
-
-
- );
- }
- return (
-
-
- }
- aria-label="App actions"
- onClick={(e: MouseEvent) => e.stopPropagation()}
- />
-
-
- : }
- onSelect={() => onPin()}
- className="whitespace-nowrap"
- >
- {isPinned ? "Unpin" : "Pin"}
-
- {onShare ? (
- }
- onSelect={() => onShare()}
- className="whitespace-nowrap"
- >
- Share
-
- ) : null}
- {onDeploy ? (
- }
- onSelect={() => onDeploy()}
- className="whitespace-nowrap"
- >
- Deploy to Vercel
-
- ) : null}
- {onDelete ? (
- }
- onSelect={() => onDelete()}
- className="whitespace-nowrap text-red-600 data-[highlighted]:text-red-700"
- >
- Delete
-
- ) : 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/chat-page.tsx b/apps/web/src/domains/chat/chat-page.tsx
index a99ea89b1e2..6b206c0869a 100644
--- a/apps/web/src/domains/chat/chat-page.tsx
+++ b/apps/web/src/domains/chat/chat-page.tsx
@@ -31,7 +31,7 @@ import {
useConversationListQuery,
} from "@/domains/conversations/conversation-queries";
import { useViewerStore } from "@/stores/viewer-store";
-import { useDeployStore } from "@/domains/chat/deploy-store";
+import { useDeployStore } from "@/stores/deploy-store";
import { useSubagentStore, type SubagentTimelineEvent } from "@/domains/subagents/subagent-store";
import type { SubagentStatus } from "@/domains/chat/api/event-types";
import { useInteractionStore } from "@/domains/interactions/interaction-store";
diff --git a/apps/web/src/domains/chat/components/chat-route-content.tsx b/apps/web/src/domains/chat/components/chat-route-content.tsx
index e2750dd47ea..c59692410b2 100644
--- a/apps/web/src/domains/chat/components/chat-route-content.tsx
+++ b/apps/web/src/domains/chat/components/chat-route-content.tsx
@@ -77,7 +77,7 @@ import { useEmptyStateGreeting } from "@/domains/chat/hooks/use-empty-state-gree
import { getChatBillingBannerDecision, shouldShowGenericChatErrorNotice } from "@/domains/chat/utils/error-classification";
import { useAssistantFeatureFlagStore } from "@/lib/feature-flags/assistant-feature-flag-store";
-import { useDeployStore } from "@/domains/chat/deploy-store";
+import { useDeployStore } from "@/stores/deploy-store";
import { useInteractionStore } from "@/domains/interactions/interaction-store";
import type { SubagentState } from "@/domains/subagents/subagent-store";
import type { DisplayAttachment, DisplayMessage } from "@/domains/chat/utils/reconcile";
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/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/domains/chat/deploy-store.test.ts b/apps/web/src/stores/deploy-store.test.ts
similarity index 98%
rename from apps/web/src/domains/chat/deploy-store.test.ts
rename to apps/web/src/stores/deploy-store.test.ts
index 8899125f1fe..37170bc2430 100644
--- a/apps/web/src/domains/chat/deploy-store.test.ts
+++ b/apps/web/src/stores/deploy-store.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, it, expect } from "bun:test";
-import { useDeployStore } from "@/domains/chat/deploy-store";
+import { useDeployStore } from "@/stores/deploy-store";
// ---------------------------------------------------------------------------
// Helpers
diff --git a/apps/web/src/domains/chat/deploy-store.ts b/apps/web/src/stores/deploy-store.ts
similarity index 79%
rename from apps/web/src/domains/chat/deploy-store.ts
rename to apps/web/src/stores/deploy-store.ts
index ecda68250ad..5821a60a23f 100644
--- a/apps/web/src/domains/chat/deploy-store.ts
+++ b/apps/web/src/stores/deploy-store.ts
@@ -1,16 +1,13 @@
/**
* Zustand store for the app share/deploy lifecycle.
*
- * Owns the in-flight UI state for two operations on the app viewer:
- * - **Share** — export the current app to a `.vellum` bundle.
- * - **Deploy** — publish the app to Vercel (with an intermediate token
+ * Owns the in-flight UI state for two operations:
+ * - **Share** — export an app to a `.vellum` bundle.
+ * - **Deploy** — publish an app to Vercel (with an intermediate token
* dialog when the org doesn't yet have a Vercel token stored).
*
- * Split out from `useViewerStore` because none of these fields relate
- * to navigation (`mainView`, `intelligenceTab`) or viewer lifecycle
- * (`openedAppState`, `openedDocumentState`); they form an independent
- * data concern and only ship together with the app-share/deploy code
- * paths.
+ * Used by both the chat-page app viewer and the library page — lives
+ * in `stores/` because it is cross-domain shared state.
*
* Reference: {@link https://zustand.docs.pmnd.rs/}
*/
@@ -18,9 +15,12 @@
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 { integrationsVercelConfigGet } from "@/generated/daemon/sdk.gen";
+import type { AppsByIdPublishPostResponse } from "@/generated/daemon/types.gen";
+import { isCredentialError } from "@/types/publish-types";
import { createSelectors } from "@/utils/create-selectors";
+import { publishApp } from "@/utils/publish-app";
+import { shareApp as shareAppApi } from "@/utils/share-app";
// ---------------------------------------------------------------------------
// Types
@@ -59,6 +59,24 @@ export interface DeployActions {
export type DeployStore = DeployState & DeployActions;
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function showPublishResultToast(result: AppsByIdPublishPostResponse): void {
+ 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");
+ }
+}
+
// ---------------------------------------------------------------------------
// Initial state
// ---------------------------------------------------------------------------
@@ -125,7 +143,10 @@ 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;
@@ -137,16 +158,8 @@ const useDeployStoreBase = create()((set, get) => ({
} 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");
+ showPublishResultToast(result);
}
} catch (err) {
toast.error("Failed to deploy", {
@@ -166,16 +179,8 @@ const useDeployStoreBase = create()((set, get) => ({
const result = await publishApp(assistantId, pendingDeployAppId);
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");
+ showPublishResultToast(result);
}
} catch (err) {
toast.error("Failed to deploy", {
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/publish-app.ts b/apps/web/src/utils/publish-app.ts
new file mode 100644
index 00000000000..594e2523f22
--- /dev/null
+++ b/apps/web/src/utils/publish-app.ts
@@ -0,0 +1,44 @@
+/**
+ * Publish an app to Vercel and enrich the response with publish-status data.
+ *
+ * When the publish endpoint returns `success: true` but omits `publicUrl`,
+ * performs a best-effort follow-up call to the publish-status endpoint to
+ * retrieve the deployed URL and deployment ID. This enrichment is
+ * transparent to callers — the returned result always has the most
+ * complete data available.
+ */
+
+import {
+ appsByIdPublishPost,
+ appsByIdPublishstatusGet,
+} from "@/generated/daemon/sdk.gen";
+import type { AppsByIdPublishPostResponse } from "@/generated/daemon/types.gen";
+
+export async function publishApp(
+ assistantId: string,
+ appId: string,
+): Promise {
+ const { data: result } = await appsByIdPublishPost({
+ path: { assistant_id: assistantId, id: appId },
+ throwOnError: true,
+ });
+
+ if (result.success && !result.publicUrl) {
+ try {
+ const { data: status } = await appsByIdPublishstatusGet({
+ path: { assistant_id: assistantId, id: appId },
+ throwOnError: true,
+ });
+ 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;
+}
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`);
+}