user app publishing system — build, serve, and use personal apps in workspace panels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 17:23:24 +00:00
co-authored by Claude Opus 4.6
parent 4d30672adb
commit fd4d77a389
17 changed files with 873 additions and 48 deletions
@@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react';
import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser';
import { appRegistryMetas as terminalMetas } from '../apps/Terminal';
import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
@@ -9,10 +10,34 @@ import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
import { appRegistryMetas as previewMetas } from '../apps/Preview';
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { useAppRegistry } from './useAppRegistry';
import { useUserApps } from 'state/useUserApps';
import { createUserAppPanel } from '../apps/UserApp/UserAppPanel';
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
import { resolveIcon } from '../utils/resolve-icon';
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas];
export const AppRegistry = () => {
useAppRegistry(apps);
const { registerApp } = useAppRegistry(apps);
const { apps: userApps, email } = useUserApps();
const registeredRef = useRef(new Set<string>());
useEffect(() => {
if (!email || userApps.length === 0) return;
for (const app of userApps) {
const key = `${email}/${app.slug}`;
if (registeredRef.current.has(key)) continue;
registeredRef.current.add(key);
registerApp(key, {
name: app.name,
icon: resolveIcon(app.icon),
component: createUserAppPanel(app.slug),
header: createUserAppHeader(app.name, resolveIcon(app.icon)),
});
}
}, [userApps, email, registerApp]);
return null;
};
@@ -1,12 +1,13 @@
import { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router';
import { FolderKanban, Plus, Pencil, Trash2, Search } from 'lucide-react';
import { FolderKanban, Plus, Pencil, Trash2, Search, Rocket } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient';
import { generateSlug } from 'helpers/slug';
import { useWorkspacesState } from 'state/useWorkspacesState';
import type { ProjectDefinition, ProjectType } from '../../components/Workspace';
import { PublishDialog } from './PublishDialog';
import {
AlertDialog,
AlertDialogAction,
@@ -53,6 +54,7 @@ export const ProjectListApp = () => {
const [search, setSearch] = useState('');
const [deleting, setDeleting] = useState<ProjectDefinition | null>(null);
const [publishing, setPublishing] = useState<ProjectDefinition | null>(null);
const isProjectsPage = location.pathname === '/projects';
const filtered = search
? projects.filter((p) => {
@@ -81,6 +83,11 @@ export const ProjectListApp = () => {
setDeleting(p);
};
const handlePublish = (ev: React.MouseEvent, p: ProjectDefinition) => {
ev.stopPropagation();
setPublishing(p);
};
const confirmDelete = () => {
if (!deleting) return;
// Optimistic: remove from cache immediately
@@ -166,6 +173,14 @@ export const ProjectListApp = () => {
<span className="text-[10px] text-duck-dark/30 shrink-0">{PROJECT_TYPE_LABELS[p.projectType]}</span>
{isProjectsPage && (
<>
<button
type="button"
onClick={(ev) => handlePublish(ev, p)}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-blue-400 transition-opacity cursor-pointer"
title="Publish"
>
<Rocket className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(ev) => handleEdit(ev, p)}
@@ -207,6 +222,15 @@ export const ProjectListApp = () => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{publishing && (
<PublishDialog
open={true}
onOpenChange={(open) => { if (!open) setPublishing(null); }}
projectSlug={publishing.id}
projectName={publishing.name}
/>
)}
</div>
);
};
@@ -0,0 +1,177 @@
import { useState } from 'react';
import { Loader2, Rocket } from 'lucide-react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { useUserApps } from 'state/useUserApps';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { resolveIcon, availableIconNames } from '../../utils/resolve-icon';
type PublishDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
projectSlug: string;
projectName: string;
};
export const PublishDialog = ({ open, onOpenChange, projectSlug, projectName }: PublishDialogProps) => {
const client = useClient();
const { refetch } = useUserApps();
const [name, setName] = useState(projectName);
const [icon, setIcon] = useState('Globe');
const [description, setDescription] = useState('');
const [publishing, setPublishing] = useState(false);
const [version, setVersion] = useState<string | null>(null);
const [iconPickerOpen, setIconPickerOpen] = useState(false);
// Fetch version from package.json when dialog opens
const fetchVersion = async () => {
try {
const res = await client.get<{ content: string }>(`/file-browser/read?path=/Projects/${projectSlug}/package.json`);
const pkg = JSON.parse(res.content);
setVersion(pkg.version ?? '0.1.0');
} catch {
setVersion('0.1.0');
}
};
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
setName(projectName);
setIcon('Globe');
setDescription('');
setPublishing(false);
fetchVersion();
}
onOpenChange(nextOpen);
};
const handlePublish = async () => {
setPublishing(true);
try {
await client.post('/apps/publish', {
projectSlug,
name,
icon,
description,
});
refetch();
toast.success(`Published ${name} successfully`);
onOpenChange(false);
} catch (err: unknown) {
const msg = err && typeof err === 'object' && 'message' in err ? String(err.message) : 'Publish failed';
toast.error(msg);
} finally {
setPublishing(false);
}
};
const SelectedIcon = resolveIcon(icon);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Rocket className="h-4 w-4" />
Publish App
</DialogTitle>
<DialogDescription>
Build and publish <strong>{projectSlug}</strong> as a standalone app.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">App Name</span>
<input
type="text"
value={name}
onChange={(ev) => setName(ev.target.value)}
className="rounded-md border border-duck-dark/15 bg-transparent px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-emerald-500/30"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Icon</span>
<div className="relative">
<button
type="button"
onClick={() => setIconPickerOpen(!iconPickerOpen)}
className="flex items-center gap-2 rounded-md border border-duck-dark/15 bg-transparent px-3 py-1.5 text-sm cursor-pointer hover:bg-duck-dark/5 w-full"
>
<SelectedIcon className="h-4 w-4" />
<span>{icon}</span>
</button>
{iconPickerOpen && (
<div className="absolute top-full left-0 mt-1 z-50 bg-background border border-duck-dark/15 rounded-md shadow-lg p-2 grid grid-cols-8 gap-1 max-h-48 overflow-y-auto w-full">
{availableIconNames.map((iconName) => {
const IconComp = resolveIcon(iconName);
return (
<button
key={iconName}
type="button"
onClick={() => {
setIcon(iconName);
setIconPickerOpen(false);
}}
className={`p-1.5 rounded cursor-pointer transition-colors ${
icon === iconName ? 'bg-emerald-500/20 text-emerald-400' : 'hover:bg-duck-dark/10'
}`}
title={iconName}
>
<IconComp className="h-4 w-4" />
</button>
);
})}
</div>
)}
</div>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Description</span>
<textarea
value={description}
onChange={(ev) => setDescription(ev.target.value)}
rows={2}
className="rounded-md border border-duck-dark/15 bg-transparent px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-emerald-500/30 resize-none"
/>
</label>
{version && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Version:</span>
<span className="font-mono">{version}</span>
</div>
)}
<button
type="button"
disabled={publishing || !name.trim()}
onClick={handlePublish}
className="flex items-center justify-center gap-2 rounded-md bg-emerald-500 hover:bg-emerald-500/90 text-white py-2 px-4 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{publishing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Publishing...
</>
) : (
<>
<Rocket className="h-4 w-4" />
Publish
</>
)}
</button>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,31 @@
import type { ComponentType } from 'react';
import type { LucideIcon } from 'lucide-react';
import { RefreshCw } from 'lucide-react';
import { useGlobal } from 'hooks/useGlobal';
type UserAppHeaderProps = { panelId: string };
export const createUserAppHeader = (name: string, Icon: LucideIcon): ComponentType<UserAppHeaderProps> => {
const UserAppHeader = ({ panelId }: UserAppHeaderProps) => {
const iframeRefreshKey = `USER_APP_REFRESH_${panelId}`;
const [, setRefresh] = useGlobal<number>(iframeRefreshKey, 0);
return (
<>
<Icon className="h-3.5 w-3.5 text-duck-teal shrink-0" />
<span className="text-xs font-medium shrink-0">{name}</span>
<button
type="button"
onClick={() => setRefresh((k) => k + 1)}
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
title="Refresh"
>
<RefreshCw className="h-3 w-3" />
</button>
</>
);
};
UserAppHeader.displayName = `UserAppHeader(${name})`;
return UserAppHeader;
};
@@ -0,0 +1,41 @@
import type { ComponentType } from 'react';
import { useState, useCallback } from 'react';
import { Loader2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
type UserAppPanelProps = { panelId: string };
export const createUserAppPanel = (slug: string): ComponentType<UserAppPanelProps> => {
const UserAppPanel = ({ panelId }: UserAppPanelProps) => {
const client = useClient();
const [iframeKey, setIframeKey] = useState(0);
const [loading, setLoading] = useState(true);
const token = client.token;
const src = token
? `/api/app-serve/${slug}/?token=${encodeURIComponent(token)}`
: `/api/app-serve/${slug}/`;
const handleLoad = useCallback(() => setLoading(false), []);
return (
<div className="relative h-full w-full">
{loading && (
<div className="absolute inset-0 flex items-center justify-center text-duck-dark/50">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
)}
<iframe
key={iframeKey}
src={src}
className="h-full w-full border-none"
title={slug}
onLoad={handleLoad}
/>
</div>
);
};
UserAppPanel.displayName = `UserAppPanel(${slug})`;
return UserAppPanel;
};
@@ -0,0 +1,2 @@
export { createUserAppPanel } from './UserAppPanel';
export { createUserAppHeader } from './UserAppHeader';
+2
View File
@@ -17,6 +17,8 @@ export { TerminalView } from './apps/Terminal';
export type { TerminalViewProps } from './apps/Terminal';
export { WorkspaceListApp, WorkspacePreview, SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './apps/Workspaces';
export { ProjectListApp, ProjectPreview, SELECTED_PROJECT, CREATING_PROJECT, EDITING_PROJECT, NEW_PROJ_NAME, NEW_PROJ_DESC, NEW_PROJ_TEMPLATE, NEW_PROJ_TYPE, NEW_PROJ_HAS_BACKEND, NEW_PROJ_HAS_AUTH, NEW_PROJ_PREVIEW_LAYOUT } from './apps/Projects';
export { createUserAppPanel, createUserAppHeader } from './apps/UserApp';
export { resolveIcon, availableIconNames } from './utils/resolve-icon';
// Workspace
export { WorkspaceView, WorkspaceLayout, WorkspaceProvider, useWorkspace } from './components/Workspace';
@@ -0,0 +1,116 @@
import type { LucideIcon } from 'lucide-react';
import {
Activity,
Airplay,
Archive,
BarChart3,
Bell,
Blocks,
BookOpen,
Box,
BrainCircuit,
Calendar,
Camera,
ChartPie,
CircleDot,
Cloud,
Code,
Compass,
CreditCard,
Database,
FileText,
Folder,
Gamepad2,
Globe,
Heart,
Home,
Image,
Inbox,
Layers,
Layout,
LineChart,
Link,
List,
Mail,
Map,
MessageCircle,
Monitor,
Music,
Palette,
PenTool,
Play,
Puzzle,
Radio,
Rocket,
Search,
Settings,
ShoppingCart,
Star,
Sun,
Table,
Terminal,
Timer,
Users,
Wand2,
Zap,
} from 'lucide-react';
const ICON_MAP: Record<string, LucideIcon> = {
Activity,
Airplay,
Archive,
BarChart3,
Bell,
Blocks,
BookOpen,
Box,
BrainCircuit,
Calendar,
Camera,
ChartPie,
CircleDot,
Cloud,
Code,
Compass,
CreditCard,
Database,
FileText,
Folder,
Gamepad2,
Globe,
Heart,
Home,
Image,
Inbox,
Layers,
Layout,
LineChart,
Link,
List,
Mail,
Map,
MessageCircle,
Monitor,
Music,
Palette,
PenTool,
Play,
Puzzle,
Radio,
Rocket,
Search,
Settings,
ShoppingCart,
Star,
Sun,
Table,
Terminal,
Timer,
Users,
Wand2,
Zap,
};
export const resolveIcon = (name: string): LucideIcon => ICON_MAP[name] ?? Box;
export const availableIconNames = Object.keys(ICON_MAP);
+2
View File
@@ -14,3 +14,5 @@ export type { ResourceSummary, ResourceDetail, PingResult } from './useResources
export { useChatSessions } from './useChatSessions';
export type { UseChatSessionsType } from './useChatSessions';
export { useChatGroups } from './useChatGroups';
export { useUserApps } from './useUserApps';
export type { AppManifest } from './useUserApps';
+34
View File
@@ -0,0 +1,34 @@
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query';
export type AppManifest = {
slug: string;
name: string;
version: string;
icon: string;
description: string;
sourceProject: string;
commitHash: string;
publishedAt: string;
buildDir: string;
};
const QUERY_KEY = ['USER_APPS'] as const;
export function useUserApps() {
const client = useClient();
const queryClient = useQueryClient();
const { user, isAuthenticated } = useAuth();
const { data: apps = [], isLoading } = useQuery<AppManifest[]>({
queryKey: QUERY_KEY,
enabled: isAuthenticated,
queryFn: () => client.get<{ apps: AppManifest[] }>('/apps').then((r) => r.apps),
staleTime: 30_000,
});
const refetch = () => queryClient.invalidateQueries({ queryKey: QUERY_KEY });
return { apps, isLoading, refetch, email: user?.email ?? '' };
}