feat(frontend): mobile-friendly flow detail header and unified picker

Make the /flows/:id page usable on a 390px viewport and replace the two
parallel template/attachment dropdowns with one trigger that works the
same way on every screen.

Header
- Breadcrumb chain (`flex min-w-0 flex-1` on the left container, `flex
  shrink-0` on the right one, `min-w-0 flex-nowrap` on BreadcrumbList,
  `min-w-0 gap-2` on BreadcrumbItem, `truncate` on BreadcrumbPage) so the
  flow title can shrink past its intrinsic width instead of pushing the
  action buttons off-screen.
- InlineEditInput: `w-64 min-w-0 max-w-full flex-1` — keeps the 256px
  default on desktop but lets the rename input collapse to whatever space
  is left on narrow viewports.
- On `isMobile`, the desktop DetailNavigationToolbar and the favorite Star
  button are hidden and re-surfaced inside the Flow-actions dropdown:
  a single row matching the theme-menu pattern in `main-sidebar.tsx`
  (icon + label + Prev/Position/Next button group sharing borders), plus
  a separate "Add/Remove favorites" item. The position button doubles as
  the sheet trigger, so the mobile navigation has the same affordances as
  the desktop toolbar.

Flow form
- Templates and Resources (formerly "Attachments") share one Ellipsis
  trigger placed next to the Send button on every viewport — the two
  separate FileText/Paperclip dropdowns are gone.
- Inside the dropdown, Radix `<Tabs>` switches between picker panels
  rendered above the tab strip; the strip lives at the bottom so it
  lands next to the trigger. The dropdown opens upward (`side="top"`
  with `align="end"`) and has a fixed `w-72` so it stays inside the
  viewport on phone-width screens.
- Tab switch is deferred one tick (`setTimeout(..., 0)`) before
  mutating `pickerTab`. Radix DropdownMenuItem listens to `pointerup`
  directly, so a synchronous swap let the pointerup that ended the tab
  click land on the freshly mounted "Upload files" item in the Resources
  panel and fired its `onSelect`.
- Send button gets `shrink-0`; combined trigger picks up `ml-auto` so
  Send/trigger stay glued to the right edge without two `ml-auto` items
  fighting over leftover space.

Detail navigation primitives
- Expose `DetailNavigationSheet` and `useNavigation` from the package
  index so a page can compose its own mobile UI without re-implementing
  the filtered-subset / Prev-Next algorithm.
- `DetailNavigationSheet`: add `pr-8` to `SheetTitle` so the trailing
  total counter (e.g. "311") stops sitting underneath the absolutely
  positioned close button on the right edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-05-15 19:03:26 +07:00
co-authored by Claude Opus 4.7
parent 829271e7e4
commit 7a2f882cea
4 changed files with 386 additions and 189 deletions
@@ -245,7 +245,7 @@ export function DetailNavigationSheet<T>({
side="right"
>
<SheetHeader className="border-b p-4">
<SheetTitle className="flex items-center gap-2 text-base">
<SheetTitle className="flex items-center gap-2 pr-8 text-base">
{sheetIcon}
<span>{sheetTitle}</span>
<span className="text-muted-foreground ml-auto text-sm font-normal tabular-nums">{total}</span>
@@ -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';
+220 -172
View File
@@ -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<HTMLInputElement>(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 `<TabsContent>` without duplicating the search-input +
// scrolled-list layout.
const renderTemplatePickerInner = () => (
<>
<DropdownMenuGroup className="-m-1 rounded-none p-0">
<InputGroup className="-mb-1 rounded-none border-0 shadow-none [&:has([data-slot=input-group-control]:focus-visible)]:border-0 [&:has([data-slot=input-group-control]:focus-visible)]:ring-0">
<InputGroupInput
onChange={(event) => setTemplateSearch(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
placeholder="Search..."
value={templateSearch}
/>
{templateSearch && (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={(event) => {
event.stopPropagation();
setTemplateSearch('');
}}
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<DropdownMenuSeparator />
</DropdownMenuGroup>
<DropdownMenuGroup className="max-h-64 overflow-y-auto">
{!filteredTemplates.length ? (
<DropdownMenuItem
className="min-h-16 justify-center"
disabled
>
{templateSearch ? 'No results found' : 'No available templates'}
</DropdownMenuItem>
) : (
filteredTemplates.map((template) => (
<DropdownMenuItem
key={template.id}
onSelect={() => {
if (isFormDisabled) {
return;
}
handleApplyTemplate(template);
}}
>
<span className="max-w-80 flex-1 truncate">{template.title}</span>
</DropdownMenuItem>
))
)}
</DropdownMenuGroup>
</>
);
const renderResourcePickerInner = () => (
<>
<DropdownMenuGroup className="-m-1 rounded-none p-0">
<InputGroup className="-mb-1 rounded-none border-0 shadow-none [&:has([data-slot=input-group-control]:focus-visible)]:border-0 [&:has([data-slot=input-group-control]:focus-visible)]:ring-0">
<InputGroupInput
onChange={(event) => setResourceSearch(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
placeholder="Search..."
value={resourceSearch}
/>
{resourceSearch && (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={(event) => {
event.stopPropagation();
setResourceSearch('');
}}
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<DropdownMenuSeparator />
</DropdownMenuGroup>
<DropdownMenuGroup className="max-h-64 overflow-y-auto">
{!filteredResources.length ? (
<DropdownMenuItem
className="min-h-16 justify-center"
disabled
>
{resourceSearch ? 'No results found' : 'No available resources'}
</DropdownMenuItem>
) : (
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 (
<DropdownMenuItem
key={resourceId}
onSelect={(event) => {
event.preventDefault();
if (isFormDisabled) {
return;
}
handleToggleAttachment(resourceId);
}}
style={{ paddingLeft: `${0.5 + depth * 0.875}rem` }}
>
<div className="flex w-full min-w-0 items-center gap-2">
<Icon className="text-muted-foreground size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">{resource.name}</span>
{isResourceSearchActive && resource.path !== resource.name && (
<span className="text-muted-foreground truncate text-xs">
{resource.path}
</span>
)}
</div>
{isSelected && <Check className="ml-auto size-4 shrink-0" />}
</div>
</DropdownMenuItem>
);
})
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={upload.isUploading}
onSelect={(event) => {
event.preventDefault();
if (isFormDisabled) {
return;
}
handleAttachClick();
}}
>
{upload.isUploading ? <Loader2 className="animate-spin" /> : <Plus />}
{upload.isUploading ? 'Uploading…' : 'Upload files'}
</DropdownMenuItem>
</>
);
return (
<Form {...form}>
<form onSubmit={handleFormSubmit(handleSubmit)}>
@@ -520,201 +679,90 @@ export const FlowForm = ({
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<InputGroupButton
disabled={isFormDisabled}
variant="ghost"
>
<FileText className="shrink-0" />
<ChevronDown />
</InputGroupButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="top"
>
<DropdownMenuGroup className="-m-1 rounded-none p-0">
<InputGroup className="-mb-1 rounded-none border-0 shadow-none [&:has([data-slot=input-group-control]:focus-visible)]:border-0 [&:has([data-slot=input-group-control]:focus-visible)]:ring-0">
<InputGroupInput
onChange={(event) => setTemplateSearch(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
placeholder="Search..."
value={templateSearch}
/>
{templateSearch && (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={(event) => {
event.stopPropagation();
setTemplateSearch('');
}}
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<DropdownMenuSeparator />
</DropdownMenuGroup>
<DropdownMenuGroup className="max-h-64 overflow-y-auto">
{!filteredTemplates.length ? (
<DropdownMenuItem
className="min-h-16 justify-center"
disabled
>
{templateSearch ? 'No results found' : 'No available templates'}
</DropdownMenuItem>
) : (
filteredTemplates.map((template) => (
<DropdownMenuItem
key={template.id}
onSelect={() => {
if (isFormDisabled) {
return;
}
handleApplyTemplate(template);
}}
>
<span className="max-w-80 flex-1 truncate">
{template.title}
</span>
</DropdownMenuItem>
))
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu
onOpenChange={(open) => {
if (!open) {
setTemplateSearch('');
setResourceSearch('');
}
}}
>
<DropdownMenuTrigger asChild>
<InputGroupButton
aria-label="Templates and resources"
className="ml-auto shrink-0"
disabled={isFormDisabled}
size="icon-xs"
variant="ghost"
>
<Paperclip className="shrink-0" />
{flowResources.length > 0 && (
<span className="bg-muted text-muted-foreground -mx-0.5 flex h-4 min-w-4 items-center justify-center rounded px-1 text-xs font-medium tabular-nums">
{flowResources.length}
</span>
)}
<ChevronDown />
<Ellipsis className="shrink-0" />
</InputGroupButton>
</DropdownMenuTrigger>
{/* 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. */}
<DropdownMenuContent
align="start"
align="end"
className="w-72"
side="top"
>
<DropdownMenuGroup className="-m-1 rounded-none p-0">
<InputGroup className="-mb-1 rounded-none border-0 shadow-none [&:has([data-slot=input-group-control]:focus-visible)]:border-0 [&:has([data-slot=input-group-control]:focus-visible)]:ring-0">
<InputGroupInput
onChange={(event) => setResourceSearch(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
placeholder="Search..."
value={resourceSearch}
/>
{resourceSearch && (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={(event) => {
event.stopPropagation();
setResourceSearch('');
}}
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<DropdownMenuSeparator />
</DropdownMenuGroup>
<DropdownMenuGroup className="max-h-64 overflow-y-auto">
{!filteredResources.length ? (
<DropdownMenuItem
className="min-h-16 justify-center"
disabled
>
{resourceSearch ? 'No results found' : 'No available resources'}
</DropdownMenuItem>
) : (
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 (
<DropdownMenuItem
key={resourceId}
onSelect={(event) => {
event.preventDefault();
if (isFormDisabled) {
return;
}
handleToggleAttachment(resourceId);
}}
style={{ paddingLeft: `${0.5 + depth * 0.875}rem` }}
>
<div className="flex w-full min-w-0 items-center gap-2">
<Icon className="text-muted-foreground size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">
{resource.name}
</span>
{isResourceSearchActive &&
resource.path !== resource.name && (
<span className="text-muted-foreground truncate text-xs">
{resource.path}
</span>
)}
</div>
{isSelected && (
<Check className="ml-auto size-4 shrink-0" />
)}
</div>
</DropdownMenuItem>
);
})
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={upload.isUploading}
onSelect={(event) => {
event.preventDefault();
if (isFormDisabled) {
return;
}
handleAttachClick();
<Tabs
onValueChange={(value) => {
// 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 ? <Loader2 className="animate-spin" /> : <Plus />}
{upload.isUploading ? 'Uploading…' : 'Upload files'}
</DropdownMenuItem>
<TabsContent
className="mt-0 focus-visible:ring-0"
value="templates"
>
{renderTemplatePickerInner()}
</TabsContent>
<TabsContent
className="mt-0 focus-visible:ring-0"
value="resources"
>
{renderResourcePickerInner()}
</TabsContent>
<TabsList className="mt-1 grid w-full grid-cols-2">
<TabsTrigger
className="gap-1.5"
value="templates"
>
<FileText className="size-3.5" />
Templates
</TabsTrigger>
<TabsTrigger
className="gap-1.5"
value="resources"
>
<Paperclip className="size-3.5" />
Resources
{flowResources.length > 0 && (
<span className="bg-muted-foreground/20 text-foreground flex h-4 min-w-4 items-center justify-center rounded px-1 text-[10px] font-medium tabular-nums">
{flowResources.length}
</span>
)}
</TabsTrigger>
</TabsList>
</Tabs>
</DropdownMenuContent>
</DropdownMenu>
{!isLoading || isSubmitting ? (
<InputGroupButton
className="ml-auto"
className="shrink-0"
disabled={isSubmitting || !isValid || upload.isUploading}
size="icon-xs"
type="submit"
@@ -724,7 +772,7 @@ export const FlowForm = ({
</InputGroupButton>
) : (
<InputGroupButton
className="ml-auto"
className="shrink-0"
disabled={isCanceling || !onCancel}
onClick={() => onCancel?.()}
size="icon-xs"
+163 -16
View File
@@ -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 `<DetailNavigationToolbar>` 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<FlowItem>({
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 = () => {
<>
<header className="bg-background sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex w-full items-center justify-between gap-2 px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<div className="flex min-w-0 flex-1 items-center gap-2">
<SidebarTrigger className="-ml-1 shrink-0" />
<Separator
className="mr-2 h-4"
className="mr-2 h-4 shrink-0"
orientation="vertical"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="gap-2">
<Breadcrumb className="min-w-0 flex-1">
<BreadcrumbList className="min-w-0 flex-nowrap">
<BreadcrumbItem className="min-w-0 gap-2">
{flow && (
<>
<FlowStatusIcon
@@ -309,7 +358,7 @@ const Flow = () => {
{isEditingTitle && flow ? (
<InlineEditInput
busy={isRenameLoading}
className="w-64 max-w-full"
className="w-64 min-w-0 max-w-full flex-1"
defaultValue={flowTitle}
inputRef={editingInputRef}
onCancel={handleFlowRenameCancel}
@@ -320,7 +369,7 @@ const Flow = () => {
<Tooltip>
<TooltipTrigger asChild>
<BreadcrumbPage
className="cursor-text select-none"
className="min-w-0 cursor-text select-none truncate"
onDoubleClick={handleFlowRenameStart}
>
{flowTitle || 'Select a flow'}
@@ -329,14 +378,16 @@ const Flow = () => {
<TooltipContent>Double-click to rename</TooltipContent>
</Tooltip>
) : (
<BreadcrumbPage>{flowTitle || 'Select a flow'}</BreadcrumbPage>
<BreadcrumbPage className="min-w-0 truncate">
{flowTitle || 'Select a flow'}
</BreadcrumbPage>
)}
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="flex items-center gap-2">
{flow && (
<div className="flex shrink-0 items-center gap-2">
{flow && !isMobile && (
<DetailNavigationToolbar<FlowItem>
{...flowToolbarProps}
renderItem={(item, isCurrent) => (
@@ -360,7 +411,7 @@ const Flow = () => {
sheetTitle="Flows"
/>
)}
{flowId && (
{flowId && !isMobile && (
<Button
className="shrink-0"
onClick={() => toggleFavoriteFlow(flowId)}
@@ -387,6 +438,70 @@ const Flow = () => {
className="min-w-24"
onCloseAutoFocus={handleDropdownCloseAutoFocus}
>
{isMobile && mobileNav.total > 0 && (
<>
{/* Single row that mirrors the desktop toolbar: label on
the left, prev / position / next button group on the
right. `onSelect={preventDefault}` stops the menu from
closing on label clicks; the inner buttons own their
own click handlers and don't bubble into menu-item
selection. Position button doubles as the sheet
trigger, matching the toolbar's middle-button role. */}
<DropdownMenuItem
className="cursor-default hover:bg-transparent focus:bg-transparent"
onSelect={(event) => event.preventDefault()}
>
<GitFork className="size-4" />
Flows
<div className="-my-1.5 -mr-2 ml-auto flex items-center">
<Button
aria-label="Previous"
className="size-7 rounded-r-none border-r-0 p-0"
disabled={!mobileNav.prevId}
onClick={() => mobileNavGoTo(mobileNav.prevId)}
size="icon"
variant="outline"
>
<ChevronLeft />
</Button>
<Button
aria-label="Open flows list"
className="h-7 min-w-12 rounded-none border-x px-2 font-mono text-xs tabular-nums"
disabled={mobileNav.currentIndex === -1}
onClick={() => setIsMobileNavSheetOpen(true)}
variant="outline"
>
{mobilePositionLabel}
</Button>
<Button
aria-label="Next"
className="size-7 rounded-l-none border-l-0 p-0"
disabled={!mobileNav.nextId}
onClick={() => mobileNavGoTo(mobileNav.nextId)}
size="icon"
variant="outline"
>
<ChevronRight />
</Button>
</div>
</DropdownMenuItem>
{flowId && (
<DropdownMenuItem onClick={() => toggleFavoriteFlow(flowId)}>
<Star
className={
isFavoriteFlow(flowId)
? 'size-4 fill-yellow-500 stroke-yellow-500'
: 'size-4'
}
/>
{isFavoriteFlow(flowId)
? 'Remove from favorites'
: 'Add to favorites'}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleFlowRenameStart}>
<PencilLine className="size-3" />
Rename
@@ -432,6 +547,38 @@ const Flow = () => {
</div>
</div>
</header>
{isMobile && flow && (
<DetailNavigationSheet<FlowItem>
currentId={flowToolbarProps.currentId}
currentIndex={mobileNav.currentIndex}
getId={flowToolbarProps.getId}
getLabel={flowToolbarProps.getLabel}
items={mobileNav.filteredItems}
onItemSelect={mobileNavSelectItem}
onOpenChange={setIsMobileNavSheetOpen}
open={isMobileNavSheetOpen}
renderItem={(item, isCurrent) => (
<>
<FlowStatusIcon
className="size-3 shrink-0"
status={item.status}
/>
<span className={isCurrent ? 'truncate font-medium' : 'truncate'}>
{item.title || `Flow #${item.id}`}
</span>
<Badge
className="ml-auto shrink-0 font-mono text-[10px]"
variant="outline"
>
#{item.id}
</Badge>
</>
)}
sheetIcon={<GitFork className="size-4" />}
sheetTitle="Flows"
total={mobileNav.total}
/>
)}
<div className="relative flex h-[calc(100dvh-3rem)] w-full max-w-full flex-1">
{isFlowLoading && (
<div className="bg-background/50 absolute inset-0 z-50 flex items-center justify-center">