Browse Source

design: improve workflow browser visual hierarchy and listing design

- Add folder icon with background per row for clear visual anchoring
- Show directory basename as secondary text under workflow name
- Right-align timestamps as de-emphasized metadata
- Add project count in header
- Richer empty state with icon and two-line message
- Border-based hover/active states instead of flat background tint
- Footer actions get small icons for scannability
- Deduplicate browseAndSetDir handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
cf797410e1
  1. 159
      src/components/quickstart/WorkflowBrowserView.tsx

159
src/components/quickstart/WorkflowBrowserView.tsx

@ -33,6 +33,10 @@ function formatRelativeTime(timestamp: number): string {
return `${months}mo ago`;
}
function dirBasename(dirPath: string): string {
return dirPath.split("/").filter(Boolean).pop() || dirPath;
}
export function WorkflowBrowserView({
onBack,
onWorkflowLoaded,
@ -71,27 +75,13 @@ export function WorkflowBrowserView({
}
}, []);
// Fetch workflows when default directory is set
useEffect(() => {
if (defaultDir) {
fetchWorkflows(defaultDir);
}
}, [defaultDir, fetchWorkflows]);
const handleChooseFolder = useCallback(async () => {
try {
const res = await fetch("/api/browse-directory");
const result = await res.json();
if (result.success && !result.cancelled && result.path) {
setWorkflowsDirectory(result.path);
setDefaultDir(result.path);
}
} catch {
setError("Failed to open directory picker");
}
}, []);
const handleChangeFolder = useCallback(async () => {
const browseAndSetDir = useCallback(async () => {
try {
const res = await fetch("/api/browse-directory");
const result = await res.json();
@ -205,7 +195,7 @@ export function WorkflowBrowserView({
</p>
</div>
<button
onClick={handleChooseFolder}
onClick={browseAndSetDir}
className="px-4 py-2 text-sm font-medium text-neutral-200 bg-neutral-700 hover:bg-neutral-600 rounded-lg transition-colors"
>
Choose folder
@ -233,82 +223,127 @@ export function WorkflowBrowserView({
{defaultDir}
</p>
</div>
<span className="text-xs text-neutral-600 tabular-nums flex-shrink-0">
{workflows.length} project{workflows.length !== 1 ? "s" : ""}
</span>
</div>
</div>
{/* Workflow list */}
<div className="flex-1 overflow-y-auto px-6 py-3">
<div className="flex-1 overflow-y-auto overscroll-contain px-4 py-2">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-5 h-5 border-2 border-neutral-600 border-t-neutral-300 rounded-full animate-spin" />
</div>
) : error && workflows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-sm text-neutral-500">{error}</p>
<svg
className="w-10 h-10 text-neutral-700 mb-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
/>
</svg>
<p className="text-sm text-neutral-400 mb-1">No workflows found</p>
<p className="text-xs text-neutral-600">
This folder doesn&apos;t contain any workflow projects
</p>
</div>
) : (
<div className="flex flex-col gap-1">
{workflows.map((entry) => (
<button
key={entry.directoryPath}
onClick={() => handleSelectWorkflow(entry)}
disabled={loadingWorkflow !== null}
className="group text-left px-4 py-3 rounded-lg hover:bg-neutral-700/40 transition-colors disabled:opacity-50"
>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium text-neutral-200 group-hover:text-neutral-100 transition-colors">
{entry.name}
</span>
<div className="mt-0.5">
<span className="text-xs text-neutral-500">
{formatRelativeTime(entry.lastModified)}
</span>
<div className="flex flex-col gap-0.5">
{workflows.map((entry) => {
const isActive = loadingWorkflow === entry.directoryPath;
return (
<button
key={entry.directoryPath}
onClick={() => handleSelectWorkflow(entry)}
disabled={loadingWorkflow !== null}
className={`
group text-left px-3 py-2.5 rounded-lg transition-all duration-100
disabled:opacity-50
${isActive
? "bg-neutral-700/60 border border-neutral-600"
: "border border-transparent hover:bg-neutral-700/30 hover:border-neutral-700/50"
}
`}
>
<div className="flex items-center gap-3">
{/* Folder icon */}
<div className={`
w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 transition-colors
${isActive ? "bg-blue-500/20" : "bg-neutral-700/50 group-hover:bg-neutral-700"}
`}>
{isActive ? (
<div className="w-3.5 h-3.5 border-2 border-blue-400/40 border-t-blue-400 rounded-full animate-spin" />
) : (
<svg
className="w-4 h-4 text-neutral-400 group-hover:text-neutral-300 transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
/>
</svg>
)}
</div>
{/* Name + folder name */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-neutral-200 group-hover:text-neutral-100 transition-colors truncate">
{entry.name}
</div>
<div className="text-[11px] text-neutral-600 truncate">
{dirBasename(entry.directoryPath)}
</div>
</div>
{/* Timestamp */}
<span className="text-[11px] text-neutral-600 tabular-nums flex-shrink-0">
{formatRelativeTime(entry.lastModified)}
</span>
</div>
{loadingWorkflow === entry.directoryPath ? (
<div className="w-4 h-4 border-2 border-neutral-600 border-t-neutral-300 rounded-full animate-spin" />
) : (
<svg
className="w-4 h-4 text-neutral-600 group-hover:text-neutral-400 transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
)}
</div>
</button>
))}
</button>
);
})}
</div>
)}
{error && workflows.length > 0 && (
<p className="text-xs text-red-400 mt-2 px-4">{error}</p>
<p className="text-xs text-red-400 mt-2 px-3">{error}</p>
)}
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-neutral-700/50 flex-shrink-0 flex items-center gap-3">
<div className="px-6 py-3 border-t border-neutral-700/50 flex-shrink-0 flex items-center gap-4">
<button
onClick={handleBrowseOther}
disabled={loadingWorkflow !== null}
className="text-xs text-neutral-400 hover:text-neutral-200 transition-colors disabled:opacity-50"
className="flex items-center gap-1.5 text-xs text-neutral-500 hover:text-neutral-300 transition-colors disabled:opacity-50"
>
Browse other location...
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
Browse other location
</button>
<span className="text-neutral-700">·</span>
<button
onClick={handleChangeFolder}
onClick={browseAndSetDir}
disabled={loadingWorkflow !== null}
className="text-xs text-neutral-400 hover:text-neutral-200 transition-colors disabled:opacity-50"
className="flex items-center gap-1.5 text-xs text-neutral-500 hover:text-neutral-300 transition-colors disabled:opacity-50"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
</svg>
Change folder
</button>
</div>

Loading…
Cancel
Save