Merge pull request #81 from ashim-hq/worktree-automate-ui-redesign

feat: redesign Automate page — tool palette + pipeline canvas
This commit is contained in:
Ashim
2026-04-21 09:27:47 +08:00
committed by GitHub
4 changed files with 681 additions and 402 deletions
@@ -344,7 +344,15 @@ function CollageCell({
}, [isSelected, image]);
const bindDrag = useDrag(
({ movement: [mx, my], first, memo }) => {
({
movement: [mx, my],
first,
memo,
}: {
movement: [number, number];
first: boolean;
memo?: { panX: number; panY: number };
}) => {
if (!image || !isSelected) return memo;
if (first) memo = { panX: transform.panX, panY: transform.panY };
const rect = cellRef.current?.getBoundingClientRect();
@@ -358,7 +366,7 @@ function CollageCell({
);
const bindPinch = usePinch(
({ offset: [scale] }) => {
({ offset: [scale] }: { offset: [number, number] }) => {
if (!image || !isSelected) return;
const zoom = Math.max(1, Math.min(10, scale));
store.setCellTransform(cellIndex, { zoom });
@@ -15,25 +15,16 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { FileImage, GripVertical, Plus, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { SearchBar } from "@/components/common/search-bar";
import { apiGet } from "@/lib/api";
import { FileImage, GripVertical, X } from "lucide-react";
import { ICON_MAP } from "@/lib/icon-map";
import { cn } from "@/lib/utils";
import type { PipelineStep } from "@/stores/pipeline-store";
import { PipelineStepSettings } from "./pipeline-step-settings";
import { getSettingsSummary } from "./pipeline-step-summary";
/** Tools that can be used as pipeline steps (excludes pipeline/batch/multi-file tools). */
const PIPELINE_TOOLS_BASE = TOOLS.filter(
(t) => !["pipeline", "compare", "find-duplicates", "collage", "compose"].includes(t.id),
);
interface PipelineBuilderProps {
steps: PipelineStep[];
expandedStepId: string | null;
onAddStep: (toolId: string) => void;
onRemoveStep: (id: string) => void;
onReorderSteps: (activeId: string, overId: string) => void;
onUpdateSettings: (id: string, settings: Record<string, unknown>) => void;
@@ -87,54 +78,59 @@ function SortableStep({
)}
>
{/* Header row - click to expand/collapse */}
<button
type="button"
onClick={onToggle}
className="flex items-center gap-2 p-3 w-full text-left"
>
{/* Drag handle */}
{
// biome-ignore lint/a11y/noStaticElementInteractions: dnd-kit drag handle spreads its own event handlers
<span
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-0.5 rounded hover:bg-muted text-muted-foreground"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<GripVertical className="h-4 w-4" />
</span>
}
{/* Step number badge */}
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-semibold flex items-center justify-center shrink-0">
{index + 1}
</span>
{/* Tool icon + name */}
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium text-foreground">{tool.name}</span>
{/* Settings summary when collapsed */}
{!isExpanded && summary && (
<span className="text-xs text-muted-foreground truncate ml-1">{summary}</span>
)}
<span className="flex-1" />
{/* Remove button */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
title="Remove"
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
{
// biome-ignore lint/a11y/useKeyWithClickEvents: keyboard handled by dnd-kit attributes
// biome-ignore lint/a11y/useSemanticElements: div with role=button avoids invalid nested <button> in HTML
<div
role="button"
tabIndex={0}
onClick={onToggle}
className="flex items-center gap-2 p-3 w-full text-left cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</button>
{/* Drag handle */}
{
// biome-ignore lint/a11y/noStaticElementInteractions: dnd-kit drag handle spreads its own event handlers
<span
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-0.5 rounded hover:bg-muted text-muted-foreground"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<GripVertical className="h-4 w-4" />
</span>
}
{/* Step number badge */}
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-semibold flex items-center justify-center shrink-0">
{index + 1}
</span>
{/* Tool icon + name */}
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium text-foreground">{tool.name}</span>
{/* Settings summary when collapsed */}
{!isExpanded && summary && (
<span className="text-xs text-muted-foreground truncate ml-1">{summary}</span>
)}
<span className="flex-1" />
{/* Remove button */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
title="Remove"
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
>
<X className="h-4 w-4" />
</button>
</div>
}
<div className={isExpanded ? "border-t border-border p-3 bg-muted/10 space-y-3" : "hidden"}>
<PipelineStepSettings
toolId={step.toolId}
@@ -142,7 +138,6 @@ function SortableStep({
onChange={onUpdateSettings}
/>
</div>
;
</div>
);
}
@@ -154,48 +149,11 @@ function SortableStep({
export function PipelineBuilder({
steps,
expandedStepId,
onAddStep,
onRemoveStep,
onReorderSteps,
onUpdateSettings,
onToggleStep,
}: PipelineBuilderProps) {
const [showToolPicker, setShowToolPicker] = useState(false);
const [toolSearch, setToolSearch] = useState("");
const [disabledTools, setDisabledTools] = useState<string[]>([]);
const [experimentalEnabled, setExperimentalEnabled] = useState(false);
const [pipelineToolIds, setPipelineToolIds] = useState<string[] | null>(null);
/* Fetch settings + pipeline-compatible tool IDs on mount */
useEffect(() => {
apiGet<{ settings: Record<string, string> }>("/v1/settings")
.then((data) => {
setDisabledTools(
data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
);
setExperimentalEnabled(data.settings.enableExperimentalTools === "true");
})
.catch(() => {});
apiGet<{ toolIds: string[] }>("/v1/pipeline/tools")
.then((data) => setPipelineToolIds(data.toolIds))
.catch(() => {});
}, []);
const PIPELINE_TOOLS = useMemo(() => {
const q = toolSearch.toLowerCase();
return PIPELINE_TOOLS_BASE.filter((t) => {
if (disabledTools.includes(t.id)) return false;
if (t.experimental && !experimentalEnabled) return false;
if (pipelineToolIds && !pipelineToolIds.includes(t.id)) return false;
if (q && !t.name.toLowerCase().includes(q) && !t.description.toLowerCase().includes(q)) {
return false;
}
return true;
});
}, [disabledTools, experimentalEnabled, pipelineToolIds, toolSearch]);
/* dnd-kit sensors */
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -208,92 +166,37 @@ export function PipelineBuilder({
}
}
function handleAddStep(toolId: string) {
onAddStep(toolId);
setShowToolPicker(false);
setToolSearch("");
if (steps.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<FileImage className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-sm font-medium text-foreground mb-1">No steps yet</h3>
<p className="text-sm text-muted-foreground max-w-[240px]">
Click tools from the palette to build your pipeline
</p>
</div>
);
}
return (
<div className="space-y-2">
{/* Sortable step list */}
{steps.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
Add steps to build your pipeline
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={steps.map((s) => s.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{steps.map((step, idx) => (
<SortableStep
key={step.id}
step={step}
index={idx}
isExpanded={expandedStepId === step.id}
onToggle={() => onToggleStep(expandedStepId === step.id ? null : step.id)}
onRemove={() => onRemoveStep(step.id)}
onUpdateSettings={(s) => onUpdateSettings(step.id, s)}
/>
))}
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={steps.map((s) => s.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{steps.map((step, idx) => (
<SortableStep
key={step.id}
step={step}
index={idx}
isExpanded={expandedStepId === step.id}
onToggle={() => onToggleStep(expandedStepId === step.id ? null : step.id)}
onRemove={() => onRemoveStep(step.id)}
onUpdateSettings={(s) => onUpdateSettings(step.id, s)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
{/* Tool picker */}
{showToolPicker ? (
<div className="rounded-lg border border-border bg-background p-3 space-y-2 max-h-80 overflow-y-auto">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">Add a step</span>
<button
type="button"
onClick={() => {
setShowToolPicker(false);
setToolSearch("");
}}
className="p-1 rounded hover:bg-muted text-muted-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<SearchBar value={toolSearch} onChange={setToolSearch} placeholder="Search tools..." />
{PIPELINE_TOOLS.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No tools found</p>
) : (
PIPELINE_TOOLS.map((tool) => {
const Icon =
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
return (
<button
key={tool.id}
type="button"
onClick={() => handleAddStep(tool.id)}
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg hover:bg-muted text-sm text-left transition-colors"
>
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-foreground">{tool.name}</div>
<div className="text-xs text-muted-foreground truncate">{tool.description}</div>
</div>
</button>
);
})
)}
</div>
) : (
<button
type="button"
onClick={() => {
setShowToolPicker(true);
setToolSearch("");
}}
className="flex items-center gap-2 w-full justify-center px-4 py-2.5 rounded-lg border border-dashed border-border text-sm text-muted-foreground hover:border-primary hover:text-primary transition-colors"
>
<Plus className="h-4 w-4" />
Add Step
</button>
)}
</div>
</SortableContext>
</DndContext>
);
}
@@ -0,0 +1,134 @@
import { CATEGORIES, TOOLS } from "@ashim/shared";
import { FileImage, Plus } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { SearchBar } from "@/components/common/search-bar";
import { apiGet } from "@/lib/api";
import { ICON_MAP } from "@/lib/icon-map";
import { cn } from "@/lib/utils";
const EXCLUDED_TOOLS = new Set(["pipeline", "compare", "find-duplicates", "collage", "compose"]);
interface ToolPaletteProps {
onAddStep: (toolId: string) => void;
className?: string;
}
export function ToolPalette({ onAddStep, className }: ToolPaletteProps) {
const [search, setSearch] = useState("");
const [disabledTools, setDisabledTools] = useState<string[]>([]);
const [experimentalEnabled, setExperimentalEnabled] = useState(false);
const [pipelineToolIds, setPipelineToolIds] = useState<string[] | null>(null);
useEffect(() => {
apiGet<{ settings: Record<string, string> }>("/v1/settings")
.then((data) => {
setDisabledTools(
data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
);
setExperimentalEnabled(data.settings.enableExperimentalTools === "true");
})
.catch(() => {});
apiGet<{ toolIds: string[] }>("/v1/pipeline/tools")
.then((data) => setPipelineToolIds(data.toolIds))
.catch(() => {});
}, []);
const availableTools = useMemo(() => {
const q = search.toLowerCase();
return TOOLS.filter((t) => {
if (EXCLUDED_TOOLS.has(t.id)) return false;
if (disabledTools.includes(t.id)) return false;
if (t.experimental && !experimentalEnabled) return false;
if (pipelineToolIds && !pipelineToolIds.includes(t.id)) return false;
if (q && !t.name.toLowerCase().includes(q) && !t.description.toLowerCase().includes(q)) {
return false;
}
return true;
});
}, [disabledTools, experimentalEnabled, pipelineToolIds, search]);
const groupedTools = useMemo(() => {
const groups: Record<string, typeof availableTools> = {};
for (const tool of availableTools) {
const cat = tool.category || "other";
if (!groups[cat]) groups[cat] = [];
groups[cat].push(tool);
}
return groups;
}, [availableTools]);
const isSearching = search.length > 0;
return (
<div className={cn("flex flex-col h-full", className)}>
<div className="px-3 pt-3 pb-2 shrink-0">
<SearchBar value={search} onChange={setSearch} placeholder="Search tools..." />
</div>
<div className="flex-1 overflow-y-auto px-3 pb-3">
{availableTools.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No tools found</p>
) : isSearching ? (
<div className="space-y-1">
{availableTools.map((tool) => (
<ToolItem key={tool.id} tool={tool} onAdd={onAddStep} />
))}
</div>
) : (
<div className="space-y-4">
{CATEGORIES.map((cat) => {
const tools = groupedTools[cat.id];
if (!tools || tools.length === 0) return null;
const CatIcon =
(ICON_MAP[cat.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
return (
<div key={cat.id}>
<div className="flex items-center gap-1.5 mb-1.5 px-1">
<CatIcon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold uppercase text-muted-foreground tracking-wider">
{cat.name}
</span>
</div>
<div className="space-y-0.5">
{tools.map((tool) => (
<ToolItem key={tool.id} tool={tool} onAdd={onAddStep} />
))}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
interface ToolItemProps {
tool: { id: string; name: string; description: string; icon: string };
onAdd: (toolId: string) => void;
}
function ToolItem({ tool, onAdd }: ToolItemProps) {
const Icon = (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
return (
<button
type="button"
onClick={() => onAdd(tool.id)}
className="flex items-center gap-2.5 w-full px-2.5 py-2 rounded-lg hover:bg-muted text-left transition-colors group"
>
<div className="p-1.5 rounded-md bg-muted group-hover:bg-primary/10 transition-colors shrink-0">
<Icon className="h-3.5 w-3.5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground leading-tight">{tool.name}</div>
<div className="text-[11px] text-muted-foreground truncate leading-tight">
{tool.description}
</div>
</div>
<Plus className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</button>
);
}