Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions frontend/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ import { Skeleton } from '@/components/ui/skeleton';
import { type ComponentCategory, getCategorySeparatorColor } from '@/utils/categoryColors';
import { useThemeStore } from '@/store/themeStore';
import { useWorkflowUiStore } from '@/store/workflowUiStore';
import { useWorkflowStore } from '@/store/workflowStore';
import { usePlacementStore } from './sidebar-state';

// Use backend-provided category configuration
// The frontend will no longer categorize components - it will use backend data

type ViewMode = 'list' | 'tiles';

import { mobilePlacementState } from './sidebar-state';

interface ComponentItemProps {
component: ComponentMetadata;
disabled?: boolean;
Expand All @@ -38,6 +38,11 @@ function ComponentItem({ component, disabled, viewMode }: ComponentItemProps) {
const description = component.description || 'No description available yet.';
const [isSelected, setIsSelected] = useState(false);

// Get placement store and workflow ID for scoped placement
const setPlacement = usePlacementStore((state) => state.setPlacement);
const placementComponentId = usePlacementStore((state) => state.componentId);
const currentWorkflowId = useWorkflowStore((state) => state.metadata.id);

const onDragStart = (event: React.DragEvent) => {
if (disabled) {
event.preventDefault();
Expand All @@ -52,10 +57,8 @@ function ComponentItem({ component, disabled, viewMode }: ComponentItemProps) {
const handleTap = () => {
if (disabled || component.deprecated) return;

// Set the component for placement
mobilePlacementState.componentId = component.id;
mobilePlacementState.componentName = component.name;
mobilePlacementState.isActive = true;
// Set the component for placement, scoped to current workflow
setPlacement(component.id, component.name, currentWorkflowId);
setIsSelected(true);

// Close sidebar after a short delay to show selection feedback
Expand All @@ -68,10 +71,10 @@ function ComponentItem({ component, disabled, viewMode }: ComponentItemProps) {

// Clear selection if this component is no longer the active one
useEffect(() => {
if (mobilePlacementState.componentId !== component.id) {
if (placementComponentId !== component.id) {
setIsSelected(false);
}
}, [component.id]);
}, [placementComponentId, component.id]);

if (viewMode === 'list') {
return (
Expand Down
77 changes: 67 additions & 10 deletions frontend/src/components/layout/sidebar-state.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,76 @@
// Global state for mobile component placement (shared between Sidebar and Canvas)
import { create } from 'zustand';

interface PlacementState {
componentId: string | null;
componentName: string | null;
isActive: boolean;
// Scope placement to a specific workflow ID
workflowId: string | null;
}

interface PlacementActions {
setPlacement: (componentId: string, componentName: string, workflowId: string | null) => void;
clearPlacement: () => void;
// Check if placement is active for a specific workflow
isPlacementActiveForWorkflow: (workflowId: string | null) => boolean;
}

/**
* Component Placement Store
* Manages the state for placing components on the canvas via spotlight/sidebar.
* Placement is scoped to a specific workflow to avoid cross-workflow interference.
*/
export const usePlacementStore = create<PlacementState & PlacementActions>((set, get) => ({
componentId: null,
componentName: null,
isActive: false,
workflowId: null,

setPlacement: (componentId, componentName, workflowId) => {
set({
componentId,
componentName,
isActive: true,
workflowId,
});
},

clearPlacement: () => {
set({
componentId: null,
componentName: null,
isActive: false,
workflowId: null,
});
},

isPlacementActiveForWorkflow: (workflowId) => {
const state = get();
// For new workflows (null ID), match if placement workflowId is also null
// For existing workflows, match by ID
return state.isActive && state.workflowId === workflowId;
},
}));

// Legacy exports for backward compatibility during migration
// TODO: Remove these after all usages are migrated to usePlacementStore
export const mobilePlacementState = {
componentId: null as string | null,
componentName: null as string | null,
isActive: false, // True when a component is selected and waiting to be placed
onSidebarClose: null as (() => void) | null, // Callback to close sidebar
get componentId() {
return usePlacementStore.getState().componentId;
},
get componentName() {
return usePlacementStore.getState().componentName;
},
get isActive() {
return usePlacementStore.getState().isActive;
},
onSidebarClose: null as (() => void) | null,
};

// Function to set the sidebar close callback
export const setMobilePlacementSidebarClose = (callback: () => void) => {
mobilePlacementState.onSidebarClose = callback;
};

// Function to clear the placement state
export const clearMobilePlacement = () => {
mobilePlacementState.componentId = null;
mobilePlacementState.componentName = null;
mobilePlacementState.isActive = false;
usePlacementStore.getState().clearPlacement();
};
54 changes: 39 additions & 15 deletions frontend/src/components/workflow/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { useToast } from '@/components/ui/use-toast';
import type { WorkflowSchedule } from '@shipsec/shared';
import { cn } from '@/lib/utils';
import { useOptionalWorkflowSchedulesContext } from '@/features/workflow-builder/contexts/useWorkflowSchedulesContext';
import { mobilePlacementState, clearMobilePlacement } from '@/components/layout/sidebar-state';
import { usePlacementStore } from '@/components/layout/sidebar-state';
import { EntryPointActionsContext } from './entry-point-context';

// Custom hook to detect mobile viewport
Expand Down Expand Up @@ -137,6 +137,10 @@ export function Canvas({
const { setConfigPanelOpen } = useWorkflowUiStore();
const isMobile = useIsMobile();

// Component placement state (for spotlight/sidebar component placement)
const placementState = usePlacementStore();
const isPlacementActive = placementState.isPlacementActiveForWorkflow(workflowId ?? null);

// Sync selection state with UI store for mobile bottom sheet visibility
useEffect(() => {
setConfigPanelOpen(Boolean(selectedNode));
Expand Down Expand Up @@ -595,7 +599,8 @@ export function Canvas({
if (mode !== 'design') return;

// Check if there's a component selected for placement (mobile flow)
if (mobilePlacementState.isActive && mobilePlacementState.componentId) {
// Only place if the placement is scoped to this workflow
if (isPlacementActive && placementState.componentId) {
let clientX: number;
let clientY: number;

Expand All @@ -612,16 +617,16 @@ export function Canvas({
}

// Create node at tap position
createNodeFromComponent(mobilePlacementState.componentId, clientX, clientY);
createNodeFromComponent(placementState.componentId, clientX, clientY);

// Clear placement state
clearMobilePlacement();
placementState.clearPlacement();

event.preventDefault();
event.stopPropagation();
}
},
[createNodeFromComponent, mode],
[createNodeFromComponent, mode, isPlacementActive, placementState],
);

// Handle node click for config panel
Expand Down Expand Up @@ -671,11 +676,28 @@ export function Canvas({
[mode],
);

// Handle pane click to deselect
const onPaneClick = useCallback(() => {
hasUserInteractedRef.current = true;
setSelectedNode(null);
}, []);
// Handle pane click to deselect or place component
const onPaneClick = useCallback(
(event: React.MouseEvent) => {
hasUserInteractedRef.current = true;

// Check if there's a component selected for placement (from spotlight/sidebar)
// Only place if the placement is scoped to this workflow
if (mode === 'design' && isPlacementActive && placementState.componentId) {
// Create node at click position
createNodeFromComponent(placementState.componentId, event.clientX, event.clientY);

// Clear placement state
placementState.clearPlacement();

return;
}

// Default behavior: deselect node
setSelectedNode(null);
},
[mode, isPlacementActive, placementState, createNodeFromComponent],
);

// Handle validation dock node click - select and scroll to node
const handleValidationNodeClick = useCallback(
Expand Down Expand Up @@ -972,8 +994,8 @@ export function Canvas({
onClick={handleCanvasTap}
onTouchEnd={handleCanvasTap}
>
{/* Mobile placement indicator - shows when a component is selected */}
{mobilePlacementState.isActive && mobilePlacementState.componentName && (
{/* Placement indicator - shows when a component is selected from spotlight/sidebar */}
{isPlacementActive && placementState.componentName && (
<div className="absolute top-[52px] left-[10px] z-50">
{/* Rotating border wrapper */}
<div
Expand All @@ -987,17 +1009,18 @@ export function Canvas({
{/* Inner pill */}
<div className="bg-background px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2">
<span className="text-xs font-medium text-foreground whitespace-nowrap">
Tap to place:{' '}
Click to place:{' '}
<span className="text-primary font-semibold">
{mobilePlacementState.componentName}
{placementState.componentName}
</span>
</span>
<button
onClick={(e) => {
e.stopPropagation();
clearMobilePlacement();
placementState.clearPlacement();
}}
className="hover:bg-muted rounded-full p-0.5 transition-colors"
aria-label="Cancel placement"
>
<svg
className="h-3.5 w-3.5 text-muted-foreground"
Expand Down Expand Up @@ -1067,6 +1090,7 @@ export function Canvas({
edgesUpdatable={mode === 'design'}
deleteKeyCode={mode === 'design' ? ['Backspace', 'Delete'] : []}
elementsSelectable
className={isPlacementActive ? '[&_.react-flow__pane]:!cursor-crosshair' : ''}
>
<Background
gap={16}
Expand Down
19 changes: 9 additions & 10 deletions frontend/src/features/command-palette/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { useCommandPaletteStore } from '@/store/commandPaletteStore';
import { useThemeStore } from '@/store/themeStore';
import { useComponentStore } from '@/store/componentStore';
import { useWorkflowUiStore } from '@/store/workflowUiStore';
import { mobilePlacementState } from '@/components/layout/sidebar-state';
import { useWorkflowStore } from '@/store/workflowStore';
import { usePlacementStore } from '@/components/layout/sidebar-state';
import { api } from '@/services/api';
import { cn } from '@/lib/utils';
import {
Expand Down Expand Up @@ -185,6 +186,9 @@ export function CommandPalette() {
}, [storeComponents]);

// Handle component placement
const setPlacement = usePlacementStore((state) => state.setPlacement);
const currentWorkflowId = useWorkflowStore((state) => state.metadata.id);

const handleComponentSelect = useCallback(
(component: ComponentMetadata) => {
// If not on workflow page, navigate to new workflow first
Expand All @@ -195,19 +199,14 @@ export function CommandPalette() {
return;
}

// Set up mobile placement state (works for both mobile and desktop now)
// The canvas will detect this and place the component
mobilePlacementState.componentId = component.id;
mobilePlacementState.componentName = component.name;
mobilePlacementState.isActive = true;
// Set up placement state scoped to the current workflow
// The canvas will detect this and place the component when clicked
setPlacement(component.id, component.name, currentWorkflowId);

// Close the command palette
close();

// For desktop, we could also trigger a center placement, but the "click to place"
// gives users more control over where the component goes
},
[isOnWorkflowPage, isOnNewWorkflowPage, navigate, close],
[isOnWorkflowPage, isOnNewWorkflowPage, navigate, close, setPlacement, currentWorkflowId],
);

// Build static commands
Expand Down