mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-24 20:16:30 +00:00
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:
co-authored by
Claude Opus 4.7
parent
829271e7e4
commit
7a2f882cea
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user