diff --git a/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx b/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx index 3c6fa88e..08704676 100644 --- a/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx +++ b/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx @@ -245,7 +245,7 @@ export function DetailNavigationSheet({ side="right" > - + {sheetIcon} {sheetTitle} {total} diff --git a/frontend/src/components/shared/detail-navigation/index.ts b/frontend/src/components/shared/detail-navigation/index.ts index 9889c03c..c05684b3 100644 --- a/frontend/src/components/shared/detail-navigation/index.ts +++ b/frontend/src/components/shared/detail-navigation/index.ts @@ -1,3 +1,5 @@ +export { DetailNavigationSheet } from './detail-navigation-sheet'; export { DetailNavigationToolbar } from './detail-navigation-toolbar'; export type { DetailNavigationToolbarProps } from './detail-navigation-toolbar'; export { useDetailNavigation } from './use-detail-navigation'; +export { useNavigation } from './use-navigation'; diff --git a/frontend/src/features/flows/flow-form.tsx b/frontend/src/features/flows/flow-form.tsx index 482c16f6..410b88f4 100644 --- a/frontend/src/features/flows/flow-form.tsx +++ b/frontend/src/features/flows/flow-form.tsx @@ -3,6 +3,7 @@ import { ArrowUp, Check, ChevronDown, + Ellipsis, FileSymlink, FileText, Folder, @@ -39,6 +40,7 @@ import { } from '@/components/ui/input-group'; import { Spinner } from '@/components/ui/spinner'; import { Switch } from '@/components/ui/switch'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useResourcesUpload } from '@/features/resources/use-resources-upload'; import { getProviderDisplayName } from '@/models/provider'; @@ -88,6 +90,11 @@ export const FlowForm = ({ const [providerSearch, setProviderSearch] = useState(''); const [templateSearch, setTemplateSearch] = useState(''); const [resourceSearch, setResourceSearch] = useState(''); + // Tracks which picker the combined dropdown is showing. Lifted to form + // state (instead of internal to the menu) so the tab choice survives + // re-renders triggered by `setTemplateSearch` / `setResourceSearch` + // inside the inner pickers. + const [pickerTab, setPickerTab] = useState<'resources' | 'templates'>('templates'); const fileInputRef = useRef(null); @@ -323,6 +330,158 @@ export const FlowForm = ({ } }, [pendingTemplate, setValue]); + // Templates and resources share the same dropdown via tabs — both picker + // bodies are kept as render functions so each can be mounted directly + // inside its `` without duplicating the search-input + + // scrolled-list layout. + const renderTemplatePickerInner = () => ( + <> + + + setTemplateSearch(event.target.value)} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + placeholder="Search..." + value={templateSearch} + /> + {templateSearch && ( + + { + event.stopPropagation(); + setTemplateSearch(''); + }} + > + + + + )} + + + + + {!filteredTemplates.length ? ( + + {templateSearch ? 'No results found' : 'No available templates'} + + ) : ( + filteredTemplates.map((template) => ( + { + if (isFormDisabled) { + return; + } + + handleApplyTemplate(template); + }} + > + {template.title} + + )) + )} + + + ); + + const renderResourcePickerInner = () => ( + <> + + + setResourceSearch(event.target.value)} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + placeholder="Search..." + value={resourceSearch} + /> + {resourceSearch && ( + + { + event.stopPropagation(); + setResourceSearch(''); + }} + > + + + + )} + + + + + {!filteredResources.length ? ( + + {resourceSearch ? 'No results found' : 'No available resources'} + + ) : ( + filteredResources.map((resource) => { + const resourceId = String(resource.id); + const isSelected = resourceIds.includes(resourceId); + const Icon = resource.isDir ? Folder : FileText; + // Depth derived from the path's slash count; ignored while a + // search query is active so matches don't appear orphaned + // beneath hidden ancestors. + const depth = isResourceSearchActive ? 0 : resource.path.split('/').length - 1; + + return ( + { + event.preventDefault(); + + if (isFormDisabled) { + return; + } + + handleToggleAttachment(resourceId); + }} + style={{ paddingLeft: `${0.5 + depth * 0.875}rem` }} + > +
+ +
+ {resource.name} + {isResourceSearchActive && resource.path !== resource.name && ( + + {resource.path} + + )} +
+ {isSelected && } +
+
+ ); + }) + )} +
+ + { + event.preventDefault(); + + if (isFormDisabled) { + return; + } + + handleAttachClick(); + }} + > + {upload.isUploading ? : } + {upload.isUploading ? 'Uploading…' : 'Upload files'} + + + ); + return (
@@ -520,201 +679,90 @@ export const FlowForm = ({ /> )} - - - - - - - - - - - setTemplateSearch(event.target.value)} - onClick={(event) => event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - placeholder="Search..." - value={templateSearch} - /> - {templateSearch && ( - - { - event.stopPropagation(); - setTemplateSearch(''); - }} - > - - - - )} - - - - - {!filteredTemplates.length ? ( - - {templateSearch ? 'No results found' : 'No available templates'} - - ) : ( - filteredTemplates.map((template) => ( - { - if (isFormDisabled) { - return; - } - - handleApplyTemplate(template); - }} - > - - {template.title} - - - )) - )} - - - - { if (!open) { + setTemplateSearch(''); setResourceSearch(''); } }} > - - {flowResources.length > 0 && ( - - {flowResources.length} - - )} - + + {/* Single upward-opening dropdown for both Templates and Resources + on every viewport. Sub-menus would get clipped on the narrowest + screens (~390px), and a unified UI keeps the form simpler than + branching on `isMobile`. The tab strip is rendered last so it + lands closest to the trigger button. */} - - - setResourceSearch(event.target.value)} - onClick={(event) => event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - placeholder="Search..." - value={resourceSearch} - /> - {resourceSearch && ( - - { - event.stopPropagation(); - setResourceSearch(''); - }} - > - - - - )} - - - - - {!filteredResources.length ? ( - - {resourceSearch ? 'No results found' : 'No available resources'} - - ) : ( - filteredResources.map((resource) => { - const resourceId = String(resource.id); - const isSelected = resourceIds.includes(resourceId); - const Icon = resource.isDir ? Folder : FileText; - // Depth derived from the path's slash count; ignored while a - // search query is active so matches don't appear orphaned - // beneath hidden ancestors. - const depth = isResourceSearchActive - ? 0 - : resource.path.split('/').length - 1; - - return ( - { - event.preventDefault(); - - if (isFormDisabled) { - return; - } - - handleToggleAttachment(resourceId); - }} - style={{ paddingLeft: `${0.5 + depth * 0.875}rem` }} - > -
- -
- - {resource.name} - - {isResourceSearchActive && - resource.path !== resource.name && ( - - {resource.path} - - )} -
- {isSelected && ( - - )} -
-
- ); - }) - )} -
- - { - event.preventDefault(); - - if (isFormDisabled) { - return; - } - - handleAttachClick(); + { + // Defer the content swap to the next task so it lands + // *after* the pointerup that the click triggered. + // Radix `DropdownMenuItem` listens to pointerup directly, + // so if we swap synchronously, the pointerup at the tab + // coordinates lands on the freshly-mounted "Upload files" + // item in the Resources panel and fires its onSelect. + setTimeout( + () => setPickerTab(value as 'resources' | 'templates'), + 0, + ); }} + value={pickerTab} > - {upload.isUploading ? : } - {upload.isUploading ? 'Uploading…' : 'Upload files'} - + + {renderTemplatePickerInner()} + + + {renderResourcePickerInner()} + + + + + Templates + + + + Resources + {flowResources.length > 0 && ( + + {flowResources.length} + + )} + + +
{!isLoading || isSubmitting ? ( ) : ( onCancel?.()} size="icon-xs" diff --git a/frontend/src/pages/flows/flow.tsx b/frontend/src/pages/flows/flow.tsx index 3037a9a0..4efa6556 100644 --- a/frontend/src/pages/flows/flow.tsx +++ b/frontend/src/pages/flows/flow.tsx @@ -1,5 +1,7 @@ import { ChevronDown, + ChevronLeft, + ChevronRight, Copy, Download, Ellipsis, @@ -13,14 +15,14 @@ import { Star, Trash, } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import { FlowStatusIcon } from '@/components/icons/flow-status-icon'; import { ProviderIcon } from '@/components/icons/provider-icon'; import ConfirmationDialog from '@/components/shared/confirmation-dialog'; -import { DetailNavigationToolbar } from '@/components/shared/detail-navigation'; +import { DetailNavigationSheet, DetailNavigationToolbar, useNavigation } from '@/components/shared/detail-navigation'; import { HeaderButton } from '@/components/shared/header-button'; import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit'; import { Badge } from '@/components/ui/badge'; @@ -45,6 +47,7 @@ import { useBreakpoint } from '@/hooks/use-breakpoint'; import { useFlowTabDetection } from '@/hooks/use-flow-tab-detection'; import { Log } from '@/lib/log'; import { copyToClipboard, downloadTextFile, generateFileName, generateReport } from '@/lib/report'; +import { mergeHrefWithSearchParams } from '@/lib/url-params'; import { formatName } from '@/lib/utils/format'; import { useFavorites } from '@/providers/favorites-provider'; import { useFlow } from '@/providers/flow-provider'; @@ -166,8 +169,9 @@ const FlowReportDropdown = () => { }; const Flow = () => { - const { isDesktop } = useBreakpoint(); + const { isDesktop, isMobile } = useBreakpoint(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { flowData, flowError, flowId, isLoading: isFlowLoading } = useFlow(); const { deleteFlow, finishFlow } = useFlows(); @@ -183,6 +187,51 @@ const Flow = () => { // persisted intent. const { toolbarProps: flowToolbarProps } = useFlowDetailNavigation(flowId); + // Mirror what `` computes internally so the + // mobile menu items (Previous / Open list / Next) and the sheet trigger + // share the same filtered subset and ordering as the desktop toolbar. + const mobileNav = useNavigation({ + currentId: flowToolbarProps.currentId, + getId: flowToolbarProps.getId, + getSearchableText: flowToolbarProps.getSearchableText ?? flowToolbarProps.getLabel, + items: flowToolbarProps.items, + query: flowToolbarProps.filter, + }); + const [isMobileNavSheetOpen, setIsMobileNavSheetOpen] = useState(false); + + const mobileNavGoTo = useCallback( + (id: null | string) => { + if (!id) { + return; + } + + const target = mobileNav.filteredItems.find((item) => String(flowToolbarProps.getId(item)) === id); + + if (!target) { + return; + } + + navigate(mergeHrefWithSearchParams(flowToolbarProps.getHref(target), searchParams), { replace: true }); + }, + [flowToolbarProps, mobileNav.filteredItems, navigate, searchParams], + ); + + const mobileNavSelectItem = useCallback( + (item: FlowItem) => { + setIsMobileNavSheetOpen(false); + navigate(mergeHrefWithSearchParams(flowToolbarProps.getHref(item), searchParams), { replace: true }); + }, + [flowToolbarProps, navigate, searchParams], + ); + + const mobilePositionLabel = useMemo( + () => + mobileNav.total === 0 || mobileNav.currentIndex === -1 + ? `–/${mobileNav.total}` + : `${mobileNav.currentIndex + 1}/${mobileNav.total}`, + [mobileNav.currentIndex, mobileNav.total], + ); + const { handleDropdownCloseAutoFocus, inputRef: editingInputRef, @@ -284,15 +333,15 @@ const Flow = () => { <>
-
- +
+ - - - + + + {flow && ( <> { {isEditingTitle && flow ? ( { {flowTitle || 'Select a flow'} @@ -329,14 +378,16 @@ const Flow = () => { Double-click to rename ) : ( - {flowTitle || 'Select a flow'} + + {flowTitle || 'Select a flow'} + )}
-
- {flow && ( +
+ {flow && !isMobile && ( {...flowToolbarProps} renderItem={(item, isCurrent) => ( @@ -360,7 +411,7 @@ const Flow = () => { sheetTitle="Flows" /> )} - {flowId && ( + {flowId && !isMobile && ( + + +
+ + {flowId && ( + toggleFavoriteFlow(flowId)}> + + {isFavoriteFlow(flowId) + ? 'Remove from favorites' + : 'Add to favorites'} + + )} + + + )} Rename @@ -432,6 +547,38 @@ const Flow = () => {
+ {isMobile && flow && ( + + currentId={flowToolbarProps.currentId} + currentIndex={mobileNav.currentIndex} + getId={flowToolbarProps.getId} + getLabel={flowToolbarProps.getLabel} + items={mobileNav.filteredItems} + onItemSelect={mobileNavSelectItem} + onOpenChange={setIsMobileNavSheetOpen} + open={isMobileNavSheetOpen} + renderItem={(item, isCurrent) => ( + <> + + + {item.title || `Flow #${item.id}`} + + + #{item.id} + + + )} + sheetIcon={} + sheetTitle="Flows" + total={mobileNav.total} + /> + )}
{isFlowLoading && (