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
44 changes: 41 additions & 3 deletions apps/playground/src/app/playground-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,47 @@ export async function renderPlaygroundShell({
: []
) as Organization[];

const organizations = allOrganizations.filter(
(o) => !o.isPersonal && !o.isChat,
);

// The Chat plan context is only the right default for subscribers (or users
// who topped up the Chat org). Unsubscribed users with a funded dashboard
// org land on that org instead; the Chat plan context stays the default only
// when no org has credits, so the plan upsell can take over. Runs before the
// chat-org fetch so redirected users never get a Chat org provisioned.
if (!orgId && !orgShareView) {
const chatPlanStatusData = await fetchServerData(
"GET",
"/chat-plans/status",
);
const chatPlanStatus =
chatPlanStatusData &&
typeof chatPlanStatusData === "object" &&
"chatPlan" in chatPlanStatusData
? (chatPlanStatusData as { chatPlan: string; regularCredits: string })
: null;
const hasChatPlanAccess =
!chatPlanStatus ||
chatPlanStatus.chatPlan !== "none" ||
Number(chatPlanStatus.regularCredits) > 0;
if (!hasChatPlanAccess) {
const fundedOrganization = organizations.find(
(o) => Number(o.credits) > 0,
);
if (fundedOrganization) {
const nextParams = new URLSearchParams();
for (const [key, value] of Object.entries(searchParams)) {
if (typeof value === "string") {
nextParams.set(key, value);
}
}
nextParams.set("orgId", fundedOrganization.id);
redirect(`/?${nextParams.toString()}`);
}
}
}

// The dedicated Chat org backs the "Personal" context
// (selectedOrganization === null): generation, billing, and top-ups all run
// under it. It is created on demand and never appears in the org switcher,
Expand All @@ -91,9 +132,6 @@ export async function renderPlaygroundShell({
"organizationId" in chatOrgData
? (chatOrgData as { organizationId: string; projectId: string })
: null;
const organizations = allOrganizations.filter(
(o) => !o.isPersonal && !o.isChat,
);

if (
orgShareView &&
Expand Down
74 changes: 66 additions & 8 deletions apps/playground/src/components/playground/canvas-page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import { JSONUIProvider, Renderer } from "@json-render/react";
import {
Code,
Download,
Eye,
FileImage,
FileText,
LayoutTemplate,
Loader2,
PenTool,
Play,
Square,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
memo,
Expand Down Expand Up @@ -65,6 +66,7 @@ import { useIsMobile } from "@/hooks/use-mobile";
import { useUser } from "@/hooks/useUser";
import { registry } from "@/lib/canvas/registry";
import { emptySpec, templates } from "@/lib/canvas/templates";
import { canvasSuggestions, sampleSuggestions } from "@/lib/hero-suggestions";
import {
CANVAS_MODEL_COOKIE,
getModelPreferenceCookie,
Expand Down Expand Up @@ -165,6 +167,58 @@ function CanvasPreviewSkeleton() {
);
}

function CanvasEmptyState({
onSuggestionClick,
}: {
onSuggestionClick: (prompt: string) => void;
}) {
const [suggestions, setSuggestions] = useState<readonly string[] | null>(
null,
);
useEffect(() => setSuggestions(sampleSuggestions(canvasSuggestions, 6)), []);

return (
<div className="flex h-full flex-col items-center justify-center py-20 text-center">
<PenTool className="h-16 w-16 text-muted-foreground/30 mb-6" />
<h3 className="text-lg font-medium mb-2">No canvas yet</h3>
<p className="text-sm text-muted-foreground mb-8 max-w-md">
Describe the UI you want to build and watch it render live, or pick a
template above.
</p>
<AnimatePresence>
{suggestions ? (
<motion.div
key="suggestions"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.07, ease: "easeOut" }}
className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-w-2xl w-full"
>
{suggestions.map((s, index) => (
<motion.button
key={s}
type="button"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.12,
delay: index * 0.025,
ease: "easeOut",
}}
onClick={() => onSuggestionClick(s)}
className="rounded-md border px-4 py-3 text-left text-sm hover:bg-muted/60 transition-colors"
>
{s}
</motion.button>
))}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}

async function getResponseErrorMessage(response: Response): Promise<string> {
const fallback = `HTTP ${response.status}: ${response.statusText}`;
const text = await response.text().catch(() => "");
Expand Down Expand Up @@ -265,7 +319,9 @@ export default function CanvasPageClient({
const promptRef = useRef<HTMLTextAreaElement>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [hasStreamingSpec, setHasStreamingSpec] = useState(false);
const [showEditor, setShowEditor] = useState(true);
// The JSON spec is an advanced feature — hidden by default so the canvas
// reads as prompt → live preview.
const [showEditor, setShowEditor] = useState(false);
const [previewExpanded, _setPreviewExpanded] = useState(false);
const [exporting, setExporting] = useState<"pdf" | "image" | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -779,13 +835,9 @@ export default function CanvasPageClient({
size="sm"
onClick={() => setShowEditor(!showEditor)}
>
{showEditor ? (
<Eye className="h-3.5 w-3.5 sm:mr-1.5" />
) : (
<Code className="h-3.5 w-3.5 sm:mr-1.5" />
)}
<Code className="h-3.5 w-3.5 sm:mr-1.5" />
<span className="hidden sm:inline">
{showEditor ? "Preview" : "Editor"}
{showEditor ? "Hide JSON" : "Show JSON"}
</span>
</Button>
{/* Hidden: expand/collapse button is redundant for now */}
Expand All @@ -812,6 +864,12 @@ export default function CanvasPageClient({
<div ref={previewRef} className="flex-1 overflow-auto p-6">
{showGenerationLoading ? (
<CanvasPreviewSkeleton />
) : isDefaultSpec && !isGenerating ? (
<CanvasEmptyState
onSuggestionClick={(suggestion) => {
void handleGenerate(suggestion);
}}
/>
) : (
<JSONUIProvider
key={specStateKey}
Expand Down
44 changes: 30 additions & 14 deletions apps/playground/src/components/playground/chat-page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,34 @@ export default function ChatPageClient({
}
};

// Billing runs under the selected dashboard org, or the dedicated Chat org
// in the Chat plan context (matching ensureCurrentChat's
// selectedOrganization?.id ?? chatOrg?.id). Without resolving the Chat org
// here, the Chat plan context skipped credit gating entirely and
// unsubscribed users without credits could keep generating.
const ensureBillableContext = () => {
if (selectedOrganization) {
// Chat plan credits live on the Chat org and never fund dashboard-org
// requests, so only the org's own credits count here.
if (Number(selectedOrganization.credits) <= 0) {
setShowTopUp(true);
return false;
}
return true;
}
if (
chatOrg &&
Number(chatOrg.credits) <= 0 &&
isChatPlanStatusLoaded &&
!hasChatPlanCredits
) {
// Chat plan context has no top-ups — promote the subscription plans.
router.push("/pricing");
return false;
}
return true;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleUserMessage = async (
content: string,
images?: Array<{
Expand All @@ -1141,13 +1169,7 @@ export default function ChatPageClient({
name?: string;
}>,
) => {
if (
selectedOrganization &&
Number(selectedOrganization.credits) <= 0 &&
isChatPlanStatusLoaded &&
!hasChatPlanCredits
) {
setShowTopUp(true);
if (!ensureBillableContext()) {
return undefined;
}

Expand Down Expand Up @@ -1318,13 +1340,7 @@ export default function ChatPageClient({
return;
}

if (
selectedOrganization &&
Number(selectedOrganization.credits) <= 0 &&
isChatPlanStatusLoaded &&
!hasChatPlanCredits
) {
setShowTopUp(true);
if (!ensureBillableContext()) {
return;
}

Expand Down
4 changes: 3 additions & 1 deletion apps/playground/src/components/playground/video-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ interface VideoControlsProps {
audioEnabled: boolean;
setAudioEnabled: (value: boolean) => void;
audioToggleDisabled: boolean;
audioToggleDisabledReason?: string;
canUseFrameInputs: boolean;
canUseReferenceInputs: boolean;
canUseReferenceVideoInputs: boolean;
Expand Down Expand Up @@ -82,6 +83,7 @@ export function VideoControls({
audioEnabled,
setAudioEnabled,
audioToggleDisabled,
audioToggleDisabledReason,
canUseFrameInputs,
canUseReferenceInputs,
canUseReferenceVideoInputs,
Expand Down Expand Up @@ -665,7 +667,7 @@ export function VideoControls({
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm"
title={
audioToggleDisabled && !isGenerating
? "Silent output is only available on Google Vertex"
? audioToggleDisabledReason
: undefined
}
>
Expand Down
8 changes: 0 additions & 8 deletions apps/playground/src/components/playground/video-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ interface VideoHeaderProps {
onRemoveModel: (index: number) => void;
comparisonMode: boolean;
onComparisonModeChange: (enabled: boolean) => void;
isModelOptionDisabled?: (value: string) => boolean;
getModelOptionDisabledReason?: (value: string) => string | undefined;
hideCompare?: boolean;
}

Expand All @@ -33,8 +31,6 @@ export function VideoHeader({
onRemoveModel,
comparisonMode,
onComparisonModeChange,
isModelOptionDisabled,
getModelOptionDisabledReason,
hideCompare = false,
}: VideoHeaderProps) {
return (
Expand All @@ -53,8 +49,6 @@ export function VideoHeader({
onValueChange={(v) => onModelChange(index, v)}
placeholder="Select model..."
mode="video"
isOptionDisabled={isModelOptionDisabled}
getOptionDisabledReason={getModelOptionDisabledReason}
/>
</div>
{selectedModels.length > 1 && (
Expand Down Expand Up @@ -90,8 +84,6 @@ export function VideoHeader({
onValueChange={(v) => onModelChange(0, v)}
placeholder="Select a video model..."
mode="video"
isOptionDisabled={isModelOptionDisabled}
getOptionDisabledReason={getModelOptionDisabledReason}
/>
</div>
)}
Expand Down
Loading
Loading