Skip to content
Draft
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
1 change: 1 addition & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.vercel
45 changes: 37 additions & 8 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ import { useEffect, useState } from "react";
import { StreamPage } from "./pages/StreamPage";
import { Toaster } from "./components/ui/sonner";
import { PipelinesProvider } from "./contexts/PipelinesContext";
import { CloudProvider } from "./lib/cloudContext";
import { CloudProvider } from "./lib/directCloudContext";
import { CloudStatusProvider } from "./hooks/useCloudStatus";
import { handleOAuthCallback, initElectronAuthListener } from "./lib/auth";
import {
handleOAuthCallback,
initElectronAuthListener,
getDaydreamUserId,
isAuthenticated,
} from "./lib/auth";
import { toast } from "sonner";
import "./index.css";

Expand All @@ -23,6 +28,22 @@ type AuthResult =
function App() {
const [isHandlingAuth, setIsHandlingAuth] = useState(true);
const [authResult, setAuthResult] = useState<AuthResult>(null);
// Track auth state for direct cloud mode - only connect when authenticated
const [isSignedIn, setIsSignedIn] = useState(isAuthenticated());

// Listen for auth changes to update cloud connection
useEffect(() => {
const handleAuthChange = () => {
setIsSignedIn(isAuthenticated());
};

window.addEventListener("daydream-auth-change", handleAuthChange);
window.addEventListener("daydream-auth-success", handleAuthChange);
return () => {
window.removeEventListener("daydream-auth-change", handleAuthChange);
window.removeEventListener("daydream-auth-success", handleAuthChange);
};
}, []);

useEffect(() => {
// Initialize Electron auth callback listener (if running in Electron)
Expand Down Expand Up @@ -92,14 +113,22 @@ function App() {
);
}

// Get user ID for direct cloud mode (sent to cloud for log correlation)
const userId = getDaydreamUserId() ?? undefined;

// Only connect to cloud if authenticated (in direct cloud mode)
// This prevents connecting before login and ensures we see the "connecting" state
const shouldConnect = !CLOUD_WS_URL || isSignedIn;
const effectiveWsUrl = shouldConnect ? CLOUD_WS_URL : undefined;

return (
<CloudStatusProvider>
<PipelinesProvider>
<CloudProvider wsUrl={CLOUD_WS_URL} apiKey={CLOUD_KEY}>
<StreamPage />
</CloudProvider>
<CloudStatusProvider skipPolling={!!CLOUD_WS_URL}>
<CloudProvider wsUrl={effectiveWsUrl} apiKey={CLOUD_KEY} userId={userId}>
<PipelinesProvider>
<StreamPage isSignedIn={isSignedIn} />
</PipelinesProvider>
<Toaster />
</PipelinesProvider>
</CloudProvider>
</CloudStatusProvider>
);
}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/ComplexFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export interface SchemaComplexFieldContext {
supportsKvCacheBias?: boolean;
isStreaming?: boolean;
isLoading?: boolean;
isCloudMode?: boolean;
isDirectCloudMode?: boolean;
/** Per-field overrides for schema-driven fields (e.g. image path). */
schemaFieldOverrides?: Record<string, unknown>;
onSchemaFieldOverrideChange?: (
Expand Down Expand Up @@ -210,7 +210,7 @@ export function SchemaComplexField({
);
}

if (component === "lora" && !rendered.has("lora") && !ctx.isCloudMode) {
if (component === "lora" && !rendered.has("lora") && !ctx.isDirectCloudMode) {
rendered.add("lora");
return (
<div key="lora" className="space-y-4">
Expand Down
41 changes: 41 additions & 0 deletions frontend/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Button } from "./ui/button";
import { SettingsDialog } from "./SettingsDialog";
import { toast } from "sonner";
import { useCloudStatus } from "../hooks/useCloudStatus";
import { useCloudContext } from "../lib/directCloudContext";

interface HeaderProps {
className?: string;
Expand All @@ -12,6 +13,8 @@ interface HeaderProps {
// External settings tab control
openSettingsTab?: string | null;
onSettingsTabOpened?: () => void;
// Auth state for direct cloud mode
isSignedIn?: boolean;
}

export function Header({
Expand All @@ -20,17 +23,28 @@ export function Header({
cloudDisabled,
openSettingsTab,
onSettingsTabOpened,
isSignedIn = true,
}: HeaderProps) {
const [settingsOpen, setSettingsOpen] = useState(false);

const [initialTab, setInitialTab] = useState<
"general" | "account" | "api-keys" | "plugins"
>("general");
const [initialPluginPath, setInitialPluginPath] = useState("");

// Get direct cloud mode from context (static, based on env var)
const { isDirectCloudMode } = useCloudContext();

// Use shared cloud status hook - single source of truth
const { isConnected, isConnecting, lastCloseCode, lastCloseReason } =
useCloudStatus();

// In direct cloud mode, determine if we need to force the settings dialog to stay open
// Require both signed in AND connected to cloud
const requiresAuth = isDirectCloudMode && !isSignedIn;
const requiresConnection = isDirectCloudMode && isSignedIn && !isConnected;
const preventClose = requiresAuth || requiresConnection;

// Track the last close code we've shown a toast for to avoid duplicates
const lastNotifiedCloseCodeRef = useRef<number | null>(null);

Expand Down Expand Up @@ -103,7 +117,32 @@ export function Header({
}
}, []);

// Force settings dialog open when in direct cloud mode and not authenticated/connected
useEffect(() => {
if (preventClose) {
setInitialTab("account");
setSettingsOpen(true);
}
}, [preventClose]);

// Track previous preventClose state to detect connection completion
const prevPreventCloseRef = useRef(preventClose);

// Auto-close settings dialog when connection completes (preventClose goes from true to false)
useEffect(() => {
if (prevPreventCloseRef.current && !preventClose && settingsOpen) {
// Connection just completed - auto-close the dialog
setSettingsOpen(false);
setInitialTab("general");
}
prevPreventCloseRef.current = preventClose;
}, [preventClose, settingsOpen]);

const handleClose = () => {
// Prevent closing if auth or connection is required
if (preventClose) {
return;
}
setSettingsOpen(false);
setInitialTab("general");
setInitialPluginPath("");
Expand Down Expand Up @@ -160,6 +199,8 @@ export function Header({
initialPluginPath={initialPluginPath}
onPipelinesRefresh={onPipelinesRefresh}
cloudDisabled={cloudDisabled}
preventClose={preventClose}
isConnecting={isConnecting}
/>
</header>
);
Expand Down
46 changes: 42 additions & 4 deletions frontend/src/components/ImageManager.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,45 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Plus, X } from "lucide-react";
import { LabelWithTooltip } from "./ui/label-with-tooltip";
import { useApi } from "../hooks/useApi";
import { getAssetUrl } from "../lib/api";
import { MediaPicker } from "./MediaPicker";

/** Helper component that loads an asset image, using data URL in cloud mode */
function AssetImage({
assetPath,
alt,
isDirectCloudMode,
getAssetDataUrl,
}: {
assetPath: string;
alt: string;
isDirectCloudMode: boolean;
getAssetDataUrl: (path: string) => Promise<string>;
}) {
const [src, setSrc] = useState<string | null>(null);

useEffect(() => {
if (isDirectCloudMode) {
getAssetDataUrl(assetPath)
.then(setSrc)
.catch(() => setSrc(null));
} else {
setSrc(getAssetUrl(assetPath));
}
}, [assetPath, isDirectCloudMode, getAssetDataUrl]);

if (!src) {
return (
<div className="w-full h-full flex items-center justify-center bg-muted">
<div className="animate-pulse w-8 h-8 bg-muted-foreground/20 rounded" />
</div>
);
}

return <img src={src} alt={alt} className="w-full h-full object-cover" />;
}

interface ImageManagerProps {
images: string[];
onImagesChange: (images: string[]) => void;
Expand All @@ -30,6 +66,7 @@ export function ImageManager({
hideLabel = false,
singleColumn,
}: ImageManagerProps) {
const { isDirectCloudMode, getAssetDataUrl } = useApi();
const [isMediaPickerOpen, setIsMediaPickerOpen] = useState(false);

const handleAddImage = (imagePath: string) => {
Expand Down Expand Up @@ -80,10 +117,11 @@ export function ImageManager({
key={index}
className="aspect-square border rounded-lg overflow-hidden relative group"
>
<img
src={getAssetUrl(imagePath)}
<AssetImage
assetPath={imagePath}
alt={`${label} ${index + 1}`}
className="w-full h-full object-cover"
isDirectCloudMode={isDirectCloudMode}
getAssetDataUrl={getAssetDataUrl}
/>
<button
onClick={() => handleRemoveImage(index)}
Expand Down
65 changes: 55 additions & 10 deletions frontend/src/components/MediaPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,56 @@
import { useState, useEffect, useRef } from "react";
import { X, Upload } from "lucide-react";
import { Button } from "./ui/button";
import {
listAssets,
uploadAsset,
getAssetUrl,
type AssetFileInfo,
} from "../lib/api";
import { useApi } from "../hooks/useApi";
import { getAssetUrl, type AssetFileInfo } from "../lib/api";

/** Helper component that loads an asset image, using data URL in cloud mode */
function AssetImage({
assetPath,
alt,
isDirectCloudMode,
getAssetDataUrl,
}: {
assetPath: string;
alt: string;
isDirectCloudMode: boolean;
getAssetDataUrl: (path: string) => Promise<string>;
}) {
const [src, setSrc] = useState<string | null>(null);
const [error, setError] = useState(false);

useEffect(() => {
if (isDirectCloudMode) {
// In cloud mode, fetch the image as a data URL
getAssetDataUrl(assetPath)
.then(setSrc)
.catch(() => setError(true));
} else {
// In local mode, use the regular URL
setSrc(getAssetUrl(assetPath));
}
}, [assetPath, isDirectCloudMode, getAssetDataUrl]);

if (error) {
return (
<div className="w-full h-full flex items-center justify-center bg-muted text-muted-foreground text-xs">
Failed to load
</div>
);
}

if (!src) {
return (
<div className="w-full h-full flex items-center justify-center bg-muted">
<div className="animate-pulse w-8 h-8 bg-muted-foreground/20 rounded" />
</div>
);
}

return (
<img src={src} alt={alt} className="w-full h-full object-cover" loading="lazy" />
);
}

interface MediaPickerProps {
isOpen: boolean;
Expand All @@ -21,6 +65,7 @@ export function MediaPicker({
onSelectImage,
disabled,
}: MediaPickerProps) {
const { listAssets, uploadAsset, isDirectCloudMode, getAssetDataUrl } = useApi();
const [images, setImages] = useState<AssetFileInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isUploading, setIsUploading] = useState(false);
Expand Down Expand Up @@ -168,11 +213,11 @@ export function MediaPicker({
className="aspect-square border rounded-lg overflow-hidden hover:ring-2 hover:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition-all relative"
title={image.name}
>
<img
src={getAssetUrl(image.path)}
<AssetImage
assetPath={image.path}
alt={image.name}
className="w-full h-full object-cover"
loading="lazy"
isDirectCloudMode={isDirectCloudMode}
getAssetDataUrl={getAssetDataUrl}
/>
</button>
))}
Expand Down
Loading