Skip to content
12 changes: 9 additions & 3 deletions assets/js/collaborative-editor/CollaborativeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { MonacoHandle } from './components/CollaborativeMonaco';
import { Header } from './components/Header';
import { LandingScreen } from './components/LandingScreen';
import { LoadingBoundary } from './components/LoadingBoundary';
import { TemplateBrowserModalWrapper } from './components/TemplateBrowserModalWrapper';
import { Toaster } from './components/ui/Toaster';
import { VersionDebugLogger } from './components/VersionDebugLogger';
import { VersionDropdown } from './components/VersionDropdown';
Expand Down Expand Up @@ -183,8 +184,12 @@ function LandingScreenWrapper({
aiAssistantEnabled: boolean;
}) {
const showLandingScreen = useShowLandingScreen();
const { openYAMLImportModal, dismissLandingScreen, openAIAssistantPanel } =
useUICommands();
const {
openYAMLImportModal,
openTemplateBrowserModal,
dismissLandingScreen,
openAIAssistantPanel,
} = useUICommands();

if (!showLandingScreen) return null;

Expand All @@ -197,10 +202,11 @@ function LandingScreenWrapper({
openAIAssistantPanel(prompt);
}}
onBuildFromScratch={() => {}}
onBrowseTemplates={() => {}}
onBrowseTemplates={openTemplateBrowserModal}
onImportYAML={openYAMLImportModal}
/>
<YAMLImportModal />
<TemplateBrowserModalWrapper />
</>
);
}
Expand Down
209 changes: 209 additions & 0 deletions assets/js/collaborative-editor/components/TemplateBrowserModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react';

import { cn } from '#/utils/cn';

import type {
BaseTemplate,
Template,
WorkflowTemplate,
} from '../types/template';

function matchesQuery(
t: { name: string; description: string | null; tags: string[] },
q: string
): boolean {
return (
t.name.toLowerCase().includes(q) ||
(t.description?.toLowerCase().includes(q) ?? false) ||
t.tags.some(tag => tag.toLowerCase().includes(q))
);
}

export function filterTemplates(
templates: WorkflowTemplate[],
q: string
): WorkflowTemplate[] {
if (!q) return templates;
return templates.filter(t => matchesQuery(t, q));
}

export interface TemplateBrowserModalProps {
isOpen: boolean;
onClose: () => void;
templates: Template[];
loading?: boolean;
isSaving?: boolean;
onSelect: (template: Template) => void;
searchQuery: string;
onSearchChange: (query: string) => void;
}

export function TemplateBrowserModal({
isOpen,
onClose,
templates,
loading = false,
isSaving = false,
onSelect,
searchQuery,
onSearchChange,
}: TemplateBrowserModalProps) {
const baseTemplates = templates.filter(
(t): t is BaseTemplate => (t as BaseTemplate).isBase === true
);
const userTemplates = templates.filter(
(t): t is WorkflowTemplate => (t as BaseTemplate).isBase !== true
);
const q = searchQuery.trim().toLowerCase();
const filteredUserTemplates = filterTemplates(userTemplates, q);
const anyBaseTemplateMatches =
q.length > 0 && baseTemplates.some(t => matchesQuery(t, q));

let cols = 1;
if (templates.length > 6) cols = 3;
else if (templates.length > 3) cols = 2;

return (
<Dialog
open={isOpen}
onClose={onClose}
className="relative z-20"
aria-label="Browse workflow templates"
>
<DialogBackdrop
transition
className="modal-backdrop data-closed:opacity-0 data-enter:duration-300
data-enter:ease-out data-leave:duration-200 data-leave:ease-in"
/>
<div className="fixed inset-0 z-10 flex items-center justify-center p-4">
<DialogPanel
transition
className={cn(
'bg-white rounded-2xl shadow-2xl w-full flex flex-col h-[560px]',
'data-closed:opacity-0 data-closed:scale-95',
'data-enter:duration-300 data-enter:ease-out',
'data-leave:duration-200 data-leave:ease-in',
{
'max-w-lg': cols === 1,
'max-w-2xl': cols === 2,
'max-w-[784px]': cols === 3,
}
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-5">
<h2 className="text-xl text-gray-900">Templates</h2>
<button
type="button"
onClick={onClose}
className="rounded-md p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
aria-label="Close"
>
<span className="hero-x-mark h-5 w-5" />
</button>
</div>

{/* Search bar — fixed, does not scroll */}
<div className="px-6">
<div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
<span className="hero-magnifying-glass h-4 w-4 text-gray-400" />
</span>
<input
type="text"
aria-label="Search templates"
placeholder="Search templates"
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
disabled={loading}
className="w-full rounded-md border border-gray-200 py-2 pl-9 pr-3 text-sm
text-gray-900 placeholder:text-gray-400
focus:outline-none focus-visible:ring-1 focus-visible:border-gray-300 focus-visible:ring-gray-300
disabled:opacity-50"
/>
</div>
</div>

{/* Content — scrollable, fills remaining panel height */}
<div className="px-6 py-5 overflow-y-auto flex-1 min-h-0">
{loading ? (
<p className="text-sm text-gray-500 text-center py-8">
Loading templates...
</p>
) : (
<div
className={cn('grid gap-4', {
'grid-cols-1': cols === 1,
'grid-cols-2': cols === 2,
'grid-cols-3': cols === 3,
})}
>
{/* Base templates are always shown unfiltered — intentional */}
{baseTemplates.map(template => (
<TemplateSelectCard
key={template.id}
template={template}
disabled={isSaving}
onClick={() => onSelect(template)}
/>
))}
{filteredUserTemplates.map(template => (
<TemplateSelectCard
key={template.id}
template={template}
disabled={isSaving}
onClick={() => onSelect(template)}
/>
))}
{userTemplates.length > 0 &&
filteredUserTemplates.length === 0 &&
searchQuery.trim() &&
!anyBaseTemplateMatches && (
<p
className={cn('text-sm text-gray-500 py-2', {
'col-span-2': cols === 2,
'col-span-3': cols === 3,
})}
>
No saved templates match your search.
</p>
)}
</div>
)}
</div>
</DialogPanel>
</div>
</Dialog>
);
}

interface TemplateSelectCardProps {
template: Template;
disabled: boolean;
onClick: () => void;
}

function TemplateSelectCard({
template,
disabled,
onClick,
}: TemplateSelectCardProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="w-full h-full text-left rounded-lg border border-gray-200 bg-white p-3
hover:border-gray-300 hover:bg-gray-50 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
focus:outline-none focus-visible:ring-1 focus-visible:ring-gray-300 focus-visible:border-gray-300"
>
<p className="text-sm font-medium text-gray-900">{template.name}</p>
{template.description && (
<p className="mt-0.5 text-sm text-gray-500 line-clamp-3">
{template.description}
</p>
)}
</button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useEffect, useState } from 'react';

import { parseWorkflowYAML, convertWorkflowSpecToState } from '../../yaml/util';
import { fetchTemplates } from '../api/templates';
import { BASE_TEMPLATES } from '../constants/baseTemplates';
import { useSession } from '../hooks/useSession';
import { useShowTemplateBrowserModal, useUICommands } from '../hooks/useUI';
import { useWorkflowActions } from '../hooks/useWorkflow';
import { useKeyboardShortcut } from '../keyboard';
import { notifications } from '../lib/notifications';
import type { Template } from '../types/template';

import { TemplateBrowserModal } from './TemplateBrowserModal';

export function TemplateBrowserModalWrapper() {
const isOpen = useShowTemplateBrowserModal();
const { closeTemplateBrowserModal, dismissLandingScreen } = useUICommands();
const provider = useSession(s => s.provider);
const channel = provider?.channel;
const { importWorkflow, saveWorkflow } = useWorkflowActions();

const [templates, setTemplates] = useState<Template[]>(BASE_TEMPLATES);
const [loading, setLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState('');

useKeyboardShortcut('Escape', closeTemplateBrowserModal, 100, {
enabled: isOpen,
});

// Lazy fetch — only when modal opens, not on every /new load
useEffect(() => {
if (!isOpen) return;
setSearchQuery('');
if (!channel) return;

const load = async () => {
setLoading(true);
try {
const userTemplates = await fetchTemplates(channel);
setTemplates([...BASE_TEMPLATES, ...userTemplates]);
} catch {
notifications.alert({
title: 'Failed to load templates',
description: 'Please check your connection and try again.',
});
} finally {
setLoading(false);
}
};

void load();
}, [isOpen, channel]);

const handleSelect = async (template: Template) => {
if (isSaving) return;
setIsSaving(true);
try {
const spec = parseWorkflowYAML(template.code);
const state = convertWorkflowSpecToState(spec);
await importWorkflow(state);
const saved = await saveWorkflow({ silent: true });
if (!saved) {
notifications.alert({
title: 'Not connected',
description: 'Connect to the server before creating a workflow.',
});
setIsSaving(false);
return;
}
setIsSaving(false);
closeTemplateBrowserModal();
dismissLandingScreen();
} catch {
notifications.alert({
title: 'Failed to create workflow',
description: 'Please check your connection and try again.',
});
setIsSaving(false);
}
};

return (
<TemplateBrowserModal
isOpen={isOpen}
onClose={closeTemplateBrowserModal}
templates={templates}
loading={loading}
isSaving={isSaving}
onSelect={template => void handleSelect(template)}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
);
}
16 changes: 16 additions & 0 deletions assets/js/collaborative-editor/hooks/useUI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export const useUICommands = () => {
dismissLandingScreen: uiStore.dismissLandingScreen,
openYAMLImportModal: uiStore.openYAMLImportModal,
closeYAMLImportModal: uiStore.closeYAMLImportModal,
openTemplateBrowserModal: uiStore.openTemplateBrowserModal,
closeTemplateBrowserModal: uiStore.closeTemplateBrowserModal,
// Import panel write commands
setImportState: uiStore.setImportState,
setImportYamlContent: uiStore.setImportYamlContent,
Expand Down Expand Up @@ -164,6 +166,20 @@ export const useShowYAMLImportModal = (): boolean => {
return useSyncExternalStore(uiStore.subscribe, selectShowYAMLImportModal);
};

/**
* Hook to check if the template browser modal is open
*/
export const useShowTemplateBrowserModal = (): boolean => {
const uiStore = useUIStore();
const selectShowTemplateBrowserModal = uiStore.withSelector(
state => state.showTemplateBrowserModal
);
return useSyncExternalStore(
uiStore.subscribe,
selectShowTemplateBrowserModal
);
};

/**
* Hook to get the entire template panel state
* Returns properly typed state - no type assertions needed
Expand Down
Loading
Loading