Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 106 additions & 7 deletions apps/web/src/domains/library/library-detail-page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,112 @@
import { useParams } from "react-router";
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";

import { toast } from "@vellum/design-library";

import { useAssistantContext } from "@/domains/chat/assistant-context.js";
import { openApp, shareApp } from "@/domains/chat/lib/apps.js";
import { AppViewerContainer } from "@/domains/intelligence/components/apps/app-viewer-container.js";
import { routes } from "@/utils/routes.js";

interface LoadedApp {
appId: string;
dirName?: string;
name: string;
html: string;
}

export function LibraryDetailPage() {
const { appId } = useParams<{ appId: string }>();
const { assistantId } = useAssistantContext();
const navigate = useNavigate();

const [app, setApp] = useState<LoadedApp | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSharing, setIsSharing] = useState(false);
const requestRef = useRef<string | null>(null);

useEffect(() => {
if (!assistantId || !appId) return;
requestRef.current = appId;
setApp(null);
setError(null);

openApp(assistantId, appId)
.then((result) => {
if (requestRef.current !== appId) return;
setApp({
appId: result.appId,
dirName: result.dirName,
name: result.name,
html: result.html,
});
})
.catch((err) => {
if (requestRef.current !== appId) return;
setError(err instanceof Error ? err.message : "Failed to open app");
});

return () => {
requestRef.current = null;
};
}, [assistantId, appId]);

const handleClose = useCallback(() => {
void navigate(routes.library.root);
}, [navigate]);

const handleShare = useCallback(async () => {
if (!assistantId || !app || isSharing) return;
setIsSharing(true);
try {
await shareApp(assistantId, app.appId, 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 {
Comment on lines +62 to +69
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle share failures to avoid unhandled promise rejections

The share callback awaits shareApp(...) without a catch, so any network/API error rejects out of the click handler. In this case users get no explicit error toast and the app can emit unhandled promise rejection noise in production logs; this is a regression from the existing LibraryView share flow, which catches and reports failures.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 552e005 — added catch + toast.error/toast.success to match LibraryView's share pattern. The try/finally without catch was a real bug (unhandled rejection + silent failure).

setIsSharing(false);
}
}, [assistantId, app, isSharing]);

if (!assistantId || !appId) return null;

if (error) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4">
<p className="text-body-medium-lighter text-[var(--content-tertiary)]">
{error}
</p>
<button
type="button"
onClick={handleClose}
className="text-body-medium-default text-[var(--primary-base)] underline"
>
Comment on lines +78 to +86
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Missing catch in handleShare causes unhandled promise rejection and silent failure

The handleShare callback has a try/finally but no catch block. When shareApp throws (e.g. network error, server error), the error propagates as an unhandled promise rejection — the user gets no feedback that the share failed, and an error appears in the console.

Comparison with existing pattern in LibraryView

The analogous handler in library-view.tsx:338-351 correctly catches errors and shows a toast:

catch (err) {
  toast.error("Failed to share app", {
    description: err instanceof Error ? err.message : undefined,
  });
}

It also shows a success toast on the happy path. The LibraryDetailPage version is missing both.

Suggested change
<div className="flex flex-1 flex-col items-center justify-center gap-4">
<p className="text-body-medium-lighter text-[var(--content-tertiary)]">
{error}
</p>
<button
type="button"
onClick={handleClose}
className="text-body-medium-default text-[var(--primary-base)] underline"
>
const handleShare = useCallback(async () => {
if (!assistantId || !app || isSharing) return;
setIsSharing(true);
try {
await shareApp(assistantId, app.appId, app.name);
} catch (err) {
console.error("Failed to share app", err);
} finally {
setIsSharing(false);
}
}, [assistantId, app, isSharing]);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 552e005 — added catch (err) with toast.error("Failed to share app", ...) and toast.success("App exported", ...), matching LibraryView's handleShareOpenedApp pattern exactly. Also added unmount cleanup (requestRef.current = null) per the vex-bot note.

Back to Library
</button>
</div>
);
}

if (!app) {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-[var(--content-tertiary)]" />
</div>
);
}

return (
<section>
<h2>Library item</h2>
<p>
Placeholder for library item <code>{appId}</code>.
</p>
</section>
<AppViewerContainer
appId={app.appId}
appName={app.name}
html={app.html}
assistantId={assistantId}
onClose={handleClose}
onShare={handleShare}
isSharing={isSharing}
/>
);
}
35 changes: 31 additions & 4 deletions apps/web/src/domains/library/library-page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,35 @@
import { useCallback } from "react";
import { useNavigate } from "react-router";

import { useAssistantContext } from "@/domains/chat/assistant-context.js";
import { LibraryView } from "@/domains/intelligence/components/apps/library-view.js";
import { routes } from "@/utils/routes.js";

export function LibraryPage() {
const { assistantId } = useAssistantContext();
const navigate = useNavigate();

const handleNewConversation = useCallback(
(_initialMessage?: string) => {
// TODO: initialMessage seeding requires cross-route state coordination
// (e.g. a Zustand store or sessionStorage handoff). The platform passes
// initialMessage directly via startNewConversation() in the same React
// tree, but here the library is a separate route. For now we just
// navigate to chat; the deploy-flow prompt handoff will come with the
// broader cross-route state work.
void navigate(routes.assistant);
},
[navigate],
);

if (!assistantId) return null;

return (
<section>
<h2>Library</h2>
<p>Placeholder route. Library listing lands with the platform/web code port.</p>
</section>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-[var(--border-base)] bg-[var(--surface-overlay)]">
<LibraryView
assistantId={assistantId}
onNewConversation={handleNewConversation}
/>
</div>
);
}