Skip to content

Commit d936bc7

Browse files
committed
fix(showcase-shell): guard demo.route in viewer + preview, share Demo type
Review feedback from CopilotKit#4196: - `[slug]/[demo]/page.tsx` constructed `${backend_url}${demo.route}` without a null check, so command-only demos (which have no `route`) rendered an iframe pointing at `${backend_url}undefined`. Now builds the src only when `demo.route` exists and renders a 'no live preview' panel otherwise, mirroring the Get Started section on the profile page. Also replaces the `any`-typed state with proper `Demo` and `Integration` types imported from `@/lib/registry`. - `[slug]/[demo]/preview/page.tsx` had the same bug — already typed but TypeScript doesn't catch template-literal coercion of undefined. Now bails with a command-focused message before concatenating. - `profile-client.tsx` no longer duplicates `Demo`/`Integration` interfaces — deleted the local copies and imports from `@/lib/registry`. copyDemoCommand's catch now logs the failure so a double-failure (no clipboard API + blocked prompt) is diagnosable. Comment above the live-demos section updated from 'Demos' to 'Live Demos' to match the rendered heading.
1 parent 630f674 commit d936bc7

3 files changed

Lines changed: 88 additions & 61 deletions

File tree

showcase/shell/src/app/integrations/[slug]/[demo]/page.tsx

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ReactMarkdown from "react-markdown";
77
import remarkGfm from "remark-gfm";
88
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
99
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
10+
import type { Demo, Integration } from "@/lib/registry";
1011

1112
type Tab = "preview" | "code" | "docs";
1213

@@ -25,28 +26,29 @@ interface DemoContent {
2526
export default function DemoViewerPage() {
2627
const params = useParams<{ slug: string; demo: string }>();
2728
const [activeTab, setActiveTab] = useState<Tab>("preview");
28-
const [integration, setIntegration] = useState<any>(null);
29-
const [demo, setDemo] = useState<any>(null);
29+
const [integration, setIntegration] = useState<Integration | null>(null);
30+
const [demo, setDemo] = useState<Demo | null>(null);
3031
const [demoContent, setDemoContent] = useState<DemoContent | null>(null);
3132
const [activeFile, setActiveFile] = useState<number>(0);
3233

3334
useEffect(() => {
3435
import("@/data/registry.json").then((mod) => {
35-
const registry = mod.default as any;
36-
const integ = registry.integrations.find(
37-
(i: any) => i.slug === params.slug,
38-
);
36+
const registry = mod.default as { integrations: Integration[] };
37+
const integ = registry.integrations.find((i) => i.slug === params.slug);
3938
if (integ) {
4039
setIntegration(integ);
41-
setDemo(integ.demos.find((d: any) => d.id === params.demo));
40+
setDemo(integ.demos.find((d) => d.id === params.demo) ?? null);
4241
}
4342
});
4443

4544
import("@/data/demo-content.json").then((mod) => {
46-
const content = mod.default as any;
45+
const content = mod.default as {
46+
demos: Record<string, DemoContent | undefined>;
47+
};
4748
const key = `${params.slug}::${params.demo}`;
48-
if (content.demos[key]) {
49-
setDemoContent(content.demos[key]);
49+
const entry = content.demos[key];
50+
if (entry) {
51+
setDemoContent(entry);
5052
}
5153
});
5254
}, [params.slug, params.demo]);
@@ -59,7 +61,12 @@ export default function DemoViewerPage() {
5961
);
6062
}
6163

62-
const iframeSrc = `${integration.backend_url}${demo.route}`;
64+
// Command-only demos (e.g. `langgraph-python::cli-start`) have no
65+
// `route`, so there's no iframe URL to build. Surface that explicitly
66+
// rather than rendering `${backend_url}undefined`.
67+
const iframeSrc = demo.route
68+
? `${integration.backend_url}${demo.route}`
69+
: null;
6370

6471
const tabs: { id: Tab; label: string }[] = [
6572
{ id: "preview", label: "Preview" },
@@ -102,15 +109,37 @@ export default function DemoViewerPage() {
102109

103110
{/* Content */}
104111
<div className="flex-1 overflow-hidden rounded-xl border border-[var(--border)]">
105-
{activeTab === "preview" && (
106-
<iframe
107-
src={iframeSrc}
108-
className="h-full w-full border-0 rounded-xl"
109-
title={`${demo.name} demo`}
110-
allow="clipboard-read; clipboard-write"
111-
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
112-
/>
113-
)}
112+
{activeTab === "preview" &&
113+
(iframeSrc ? (
114+
<iframe
115+
src={iframeSrc}
116+
className="h-full w-full border-0 rounded-xl"
117+
title={`${demo.name} demo`}
118+
allow="clipboard-read; clipboard-write"
119+
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
120+
/>
121+
) : (
122+
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-[var(--text-muted)]">
123+
<p className="text-sm font-semibold text-[var(--text)]">
124+
No live preview for this demo
125+
</p>
126+
<p className="text-xs">
127+
{demo.name} is a CLI-only demo. See the Docs tab or
128+
{demo.command ? (
129+
<>
130+
{" "}
131+
run{" "}
132+
<code className="rounded bg-[var(--bg-elevated)] px-1.5 py-0.5 font-mono text-[var(--accent)]">
133+
{demo.command}
134+
</code>{" "}
135+
to get started.
136+
</>
137+
) : (
138+
" the integration page for instructions."
139+
)}
140+
</p>
141+
</div>
142+
))}
114143

115144
{activeTab === "code" && (
116145
<div className="flex h-full">

showcase/shell/src/app/integrations/[slug]/[demo]/preview/page.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,34 @@ export default function StandalonePreviewPage() {
2828
);
2929
}
3030

31+
// Command-only demos (no `route`) have no iframe URL to build —
32+
// concatenating them would produce `${base}undefined`. Render the
33+
// command instead, matching the Get Started section on the profile page.
34+
if (!demo.route) {
35+
return (
36+
<div className="flex h-[calc(100vh-52px)] w-full items-center justify-center px-6">
37+
<div className="max-w-md text-center">
38+
<p className="text-sm font-semibold text-[var(--text)]">
39+
{demo.name} has no live preview
40+
</p>
41+
<p className="mt-2 text-xs text-[var(--text-muted)]">
42+
This demo is CLI-only.
43+
{demo.command ? (
44+
<>
45+
{" "}
46+
Run{" "}
47+
<code className="rounded bg-[var(--bg-elevated)] px-1.5 py-0.5 font-mono text-[var(--accent)]">
48+
{demo.command}
49+
</code>{" "}
50+
to get started.
51+
</>
52+
) : null}
53+
</p>
54+
</div>
55+
</div>
56+
);
57+
}
58+
3159
let localBackends: Record<string, string> = {};
3260
try {
3361
const raw = process.env.NEXT_PUBLIC_LOCAL_BACKENDS;

showcase/shell/src/app/integrations/[slug]/profile-client.tsx

Lines changed: 11 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,45 +8,7 @@ import ReactMarkdown from "react-markdown";
88
import remarkGfm from "remark-gfm";
99
import rehypeRaw from "rehype-raw";
1010
import { DemoDrawer } from "@/components/demo-drawer";
11-
12-
interface Demo {
13-
id: string;
14-
name: string;
15-
description: string;
16-
tags: string[];
17-
route?: string;
18-
command?: string;
19-
animated_preview_url?: string | null;
20-
}
21-
22-
interface Integration {
23-
name: string;
24-
slug: string;
25-
category: string;
26-
language: string;
27-
logo?: string;
28-
description: string;
29-
partner_docs: string | null;
30-
repo: string;
31-
copilotkit_version?: string;
32-
backend_url: string;
33-
deployed: boolean;
34-
generative_ui?: string[];
35-
interaction_modalities?: string[];
36-
sort_order?: number;
37-
managed_platform?: { name: string; url: string };
38-
animated_preview_url?: string | null;
39-
starter?: {
40-
path: string;
41-
name: string;
42-
description?: string;
43-
github_url?: string;
44-
demo_url?: string;
45-
clone_command?: string;
46-
};
47-
features: string[];
48-
demos: Demo[];
49-
}
11+
import type { Demo, Integration } from "@/lib/registry";
5012

5113
interface StarterFile {
5214
filename: string;
@@ -125,7 +87,15 @@ export function ProfileClient({
12587
setCopiedCommandId(demoId);
12688
setTimeout(() => setCopiedCommandId(null), 2000);
12789
})
128-
.catch(() => {
90+
.catch((err: unknown) => {
91+
// navigator.clipboard requires HTTPS — fall back to window.prompt so
92+
// the user can still copy manually. Log the original failure so
93+
// missing-clipboard-and-blocked-prompt double failures are
94+
// diagnosable via devtools.
95+
console.warn(
96+
"[profile] clipboard write failed, falling back to prompt:",
97+
err,
98+
);
12999
window.prompt("Copy this command:", command);
130100
});
131101
}
@@ -490,7 +460,7 @@ export function ProfileClient({
490460
</section>
491461
)}
492462

493-
{/* Demos */}
463+
{/* Live Demos */}
494464
{liveDemos.length > 0 && (
495465
<section className="mt-10">
496466
<h2 className="mb-4 text-xs font-mono uppercase tracking-widest text-[var(--text-muted)]">

0 commit comments

Comments
 (0)