From 6a77ec22df6c36b85d472eef0062d9e9120fa2e6 Mon Sep 17 00:00:00 2001 From: brunorezio Date: Sun, 26 Jul 2026 02:26:48 +0100 Subject: [PATCH] group file-browser tasks into category submenus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every task declares a directory trigger, so right-clicking any folder listed all fifteen at once — Tag Album offered on a folder of photos. TASK.md gains an optional `category`, and the context menu nests by it: Run Task > Video > … Nesting only kicks in when more than one category matches. A .mp4 matches eight tasks that are all Video, so file menus stay flat rather than gaining a pointless hop. Known categories lead (Video, Audio, Cleanup); anything else follows alphabetically with Other last. Applied to both menus — the right-click one and the ⋮ dropdown — which carried identical blocks. Co-Authored-By: Claude Opus 5 --- src/servers/api/tasks/task-files.ts | 6 + src/servers/api/tasks/tasks.ts | 2 + .../FileBrowserApp/components/FileItem.tsx | 441 ++++++++++-------- .../src/apps/FileBrowser/useTasks.ts | 30 ++ 4 files changed, 297 insertions(+), 182 deletions(-) diff --git a/src/servers/api/tasks/task-files.ts b/src/servers/api/tasks/task-files.ts index f808e3b1..2feb542b 100644 --- a/src/servers/api/tasks/task-files.ts +++ b/src/servers/api/tasks/task-files.ts @@ -11,6 +11,7 @@ const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAM export type TaskFrontmatter = { name: string; description: string | null; + category: string | null; // groups the task into a context-menu submenu; null falls back to 'Other' version: number; mode: string; inline: boolean; // quick/interactive tasks run in the modal instead of as a background job @@ -37,6 +38,7 @@ export type TaskSummary = { dirName: string; name: string; description: string | null; + category: string | null; mode: string; inline: boolean; version: number; @@ -47,6 +49,7 @@ export type TaskSummary = { const FM_KEYS = [ 'name', 'description', + 'category', 'version', 'mode', 'inline', @@ -106,6 +109,7 @@ function parseTaskMd(raw: string): { fm: TaskFrontmatter; body: string } { fm: { name: parsed.name == null ? '' : String(parsed.name), description: parsed.description == null ? null : String(parsed.description), + category: parsed.category == null ? null : String(parsed.category), version, mode: parsed.mode == null ? 'agentic' : String(parsed.mode), inline: parsed.inline === true, @@ -163,6 +167,7 @@ export async function listTasks(): Promise { dirName: entry.name, name: fm.name || entry.name, description: fm.description, + category: fm.category, mode: fm.mode, inline: fm.inline, version: fm.version, @@ -209,6 +214,7 @@ export async function createTask(input: CreateTaskInput): Promise { dirName: row.dirName, name: row.name, description: row.description, + category: row.category, triggers: (row.trigger as TriggerConfig[]) ?? [], mode: row.mode ?? 'agentic', })); @@ -29,6 +30,7 @@ tasksRouter.get('/:name', async (ctx) => { dirName: task.dirName, name: task.name, description: task.description, + category: task.category, mode: task.mode, inline: task.inline, language: task.language, diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx index 56919a72..ba0ed33b 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx @@ -1,5 +1,24 @@ import { useState, useEffect, useRef } from 'react'; -import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid, Volume2, ScanText, FileText, AudioLines, FolderArchive, ClipboardCopy, Music } from 'lucide-react'; +import { + Folder, + Trash2, + Pencil, + MoreVertical, + MessageSquare, + Scissors, + Copy, + Check, + Play, + Download, + LayoutGrid, + Volume2, + ScanText, + FileText, + AudioLines, + FolderArchive, + ClipboardCopy, + Music, +} from 'lucide-react'; import { getIcon } from 'material-file-icons'; import { DropdownMenu, @@ -23,7 +42,7 @@ import { } from '@/components/ui/context-menu'; import { cardStyle } from '@/components/Card'; import type { DirEntry } from '../../../../hooks/useFilesAPI'; -import type { TaskSummary } from '../../useTasks'; +import { groupTasksByCategory, type TaskSummary } from '../../useTasks'; import { getFileType } from '../../../FileViewer'; export type FileItemProps = { @@ -118,101 +137,133 @@ const DropdownMenuItems = ({ const showExtract = fileType === 'archive'; const showPlay = fileType === 'audio' || entry.type === 'directory'; - const hasActions = showPlay || showReadAloud || showOcr || showTranscribe || showExtractAudio || showExtract || matchingTasks.length > 0; + const hasActions = + showPlay || + showReadAloud || + showOcr || + showTranscribe || + showExtractAudio || + showExtract || + matchingTasks.length > 0; + // Only nest when more than one category is present — a file usually matches a single category, + // and Run Task > Video > Convert would just add a hop. + const taskGroups = groupTasksByCategory(matchingTasks); + const nestTasks = taskGroups.length > 1; return ( - <> - {showPlay && ( - onPlay(entry)} className="cursor-pointer"> - - Play + <> + {showPlay && ( + onPlay(entry)} className="cursor-pointer"> + + Play + + )} + {showReadAloud && ( + onReadAloud(entry)} className="cursor-pointer"> + + Read Aloud + + )} + {showOcr && ( + onOcr(entry)} className="cursor-pointer"> + + Extract Text (OCR) + + )} + {showTranscribe && ( + onTranscribe(entry)} className="cursor-pointer"> + + Transcribe + + )} + {showExtractAudio && ( + onExtractAudio(entry)} className="cursor-pointer"> + + Extract Audio + + )} + {showExtract && ( + onExtract(entry)} className="cursor-pointer"> + + Extract + + )} + {matchingTasks.length > 0 && ( + + + + Run Task + + + {nestTasks + ? taskGroups.map((group) => ( + + {group.category} + + {group.tasks.map((task) => ( + onRunTask(task, entry)} + className="cursor-pointer" + > + {task.name} + + ))} + + + )) + : matchingTasks.map((task) => ( + onRunTask(task, entry)} + className="cursor-pointer" + > + {task.name} + + ))} + + + )} + {hasActions && } + + + Cut - )} - {showReadAloud && ( - onReadAloud(entry)} className="cursor-pointer"> - - Read Aloud + + + Copy - )} - {showOcr && ( - onOcr(entry)} className="cursor-pointer"> - - Extract Text (OCR) + {!multiSelected && ( + + + Rename + + )} + onCopyPath(entry)} className="cursor-pointer"> + + Copy path - )} - {showTranscribe && ( - onTranscribe(entry)} className="cursor-pointer"> - - Transcribe + onDownload(entry)} className="cursor-pointer"> + + Download - )} - {showExtractAudio && ( - onExtractAudio(entry)} className="cursor-pointer"> - - Extract Audio + + onChat(entry)} className="cursor-pointer"> + + Chat... - )} - {showExtract && ( - onExtract(entry)} className="cursor-pointer"> - - Extract + {entry.type === 'directory' && ( + onCreateDashboard(entry)} className="cursor-pointer"> + + Create Dashboard here + + )} + + onDelete(entry)} className="text-red-600 cursor-pointer"> + + Delete - )} - {matchingTasks.length > 0 && ( - - - - Run Task - - - {matchingTasks.map((task) => ( - onRunTask(task, entry)} className="cursor-pointer"> - {task.name} - - ))} - - - )} - {hasActions && } - - - Cut - - - - Copy - - {!multiSelected && ( - - - Rename - - )} - onCopyPath(entry)} className="cursor-pointer"> - - Copy path - - onDownload(entry)} className="cursor-pointer"> - - Download - - - onChat(entry)} className="cursor-pointer"> - - Chat... - - {entry.type === 'directory' && ( - onCreateDashboard(entry)} className="cursor-pointer"> - - Create Dashboard here - - )} - - onDelete(entry)} className="text-red-600 cursor-pointer"> - - Delete - - + ); }; @@ -244,101 +295,129 @@ const ContextMenuItems = ({ const showExtract = fileType === 'archive'; const showPlay = fileType === 'audio' || entry.type === 'directory'; - const hasActions = showPlay || showReadAloud || showOcr || showTranscribe || showExtractAudio || showExtract || matchingTasks.length > 0; + const hasActions = + showPlay || + showReadAloud || + showOcr || + showTranscribe || + showExtractAudio || + showExtract || + matchingTasks.length > 0; + // Only nest when more than one category is present — a file usually matches a single category, + // and Run Task > Video > Convert would just add a hop. + const taskGroups = groupTasksByCategory(matchingTasks); + const nestTasks = taskGroups.length > 1; return ( - <> - {showPlay && ( - onPlay(entry)} className="cursor-pointer"> - - Play + <> + {showPlay && ( + onPlay(entry)} className="cursor-pointer"> + + Play + + )} + {showReadAloud && ( + onReadAloud(entry)} className="cursor-pointer"> + + Read Aloud + + )} + {showOcr && ( + onOcr(entry)} className="cursor-pointer"> + + Extract Text (OCR) + + )} + {showTranscribe && ( + onTranscribe(entry)} className="cursor-pointer"> + + Transcribe + + )} + {showExtractAudio && ( + onExtractAudio(entry)} className="cursor-pointer"> + + Extract Audio + + )} + {showExtract && ( + onExtract(entry)} className="cursor-pointer"> + + Extract + + )} + {matchingTasks.length > 0 && ( + + + + Run Task + + + {nestTasks + ? taskGroups.map((group) => ( + + {group.category} + + {group.tasks.map((task) => ( + onRunTask(task, entry)} + className="cursor-pointer" + > + {task.name} + + ))} + + + )) + : matchingTasks.map((task) => ( + onRunTask(task, entry)} className="cursor-pointer"> + {task.name} + + ))} + + + )} + {hasActions && } + + + Cut - )} - {showReadAloud && ( - onReadAloud(entry)} className="cursor-pointer"> - - Read Aloud + + + Copy - )} - {showOcr && ( - onOcr(entry)} className="cursor-pointer"> - - Extract Text (OCR) + {!multiSelected && ( + + + Rename + + )} + onCopyPath(entry)} className="cursor-pointer"> + + Copy path - )} - {showTranscribe && ( - onTranscribe(entry)} className="cursor-pointer"> - - Transcribe + onDownload(entry)} className="cursor-pointer"> + + Download - )} - {showExtractAudio && ( - onExtractAudio(entry)} className="cursor-pointer"> - - Extract Audio + + onChat(entry)} className="cursor-pointer"> + + Chat... - )} - {showExtract && ( - onExtract(entry)} className="cursor-pointer"> - - Extract + {entry.type === 'directory' && ( + onCreateDashboard(entry)} className="cursor-pointer"> + + Create Dashboard here + + )} + + onDelete(entry)} className="text-red-600 cursor-pointer"> + + Delete - )} - {matchingTasks.length > 0 && ( - - - - Run Task - - - {matchingTasks.map((task) => ( - onRunTask(task, entry)} className="cursor-pointer"> - {task.name} - - ))} - - - )} - {hasActions && } - - - Cut - - - - Copy - - {!multiSelected && ( - - - Rename - - )} - onCopyPath(entry)} className="cursor-pointer"> - - Copy path - - onDownload(entry)} className="cursor-pointer"> - - Download - - - onChat(entry)} className="cursor-pointer"> - - Chat... - - {entry.type === 'directory' && ( - onCreateDashboard(entry)} className="cursor-pointer"> - - Create Dashboard here - - )} - - onDelete(entry)} className="text-red-600 cursor-pointer"> - - Delete - - + ); }; @@ -431,7 +510,9 @@ const Checkbox = ({ onClick(ev); }} className={`flex items-center justify-center h-5 w-5 rounded border-2 transition-all cursor-pointer ${ - checked ? 'bg-duck-teal border-duck-teal text-white' : 'border-duck-dark/30 bg-background/80 hover:border-duck-teal/50' + checked + ? 'bg-duck-teal border-duck-teal text-white' + : 'border-duck-dark/30 bg-background/80 hover:border-duck-teal/50' } ${anySelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`} > {checked && } @@ -614,11 +695,7 @@ export const FileItem = ({ className={`group relative rounded-lg border-2 p-4 flex flex-col items-center gap-2 hover:border-duck-teal/50 data-[state=open]:border-duck-teal/50 transition-colors cursor-pointer ${ selected ? 'border-duck-teal/50 bg-duck-teal/10' : 'border-duck-dark/20' } ${cutOpacity} select-none`} - style={ - selected - ? undefined - : cardStyle() - } + style={selected ? undefined : cardStyle()} onClick={handleClick} onDoubleClick={handleDoubleClick} onPointerDown={(ev) => ev.stopPropagation()} diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts index e000ba54..f2af9f51 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts @@ -9,11 +9,41 @@ export type TaskSummary = { name: string; description: string; scope: string; + category: string | null; triggers: TriggerConfig[]; mode: 'script' | 'agentic' | 'pipeline'; userId: number | null; }; +export type TaskGroup = { category: string; tasks: TaskSummary[] }; + +const UNCATEGORIZED = 'Other'; + +// Every task carries a `directory` trigger, so right-clicking a folder listed all of them at once. +// Grouping by category turns that into Run Task > Video > … Known categories lead in this order; +// anything else follows alphabetically, with Other always last. +const CATEGORY_ORDER = ['Video', 'Audio', 'Cleanup']; + +const categoryRank = (category: string): number => { + const known = CATEGORY_ORDER.indexOf(category); + if (known !== -1) return known; + return category === UNCATEGORIZED ? Number.MAX_SAFE_INTEGER : CATEGORY_ORDER.length; +}; + +export const groupTasksByCategory = (tasks: TaskSummary[]): TaskGroup[] => { + const groups = new Map(); + for (const task of tasks) { + const category = task.category?.trim() || UNCATEGORIZED; + const bucket = groups.get(category); + if (bucket) bucket.push(task); + else groups.set(category, [task]); + } + + return Array.from(groups.entries()) + .map(([category, list]) => ({ category, tasks: [...list].sort((a, b) => a.name.localeCompare(b.name)) })) + .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.category.localeCompare(b.category)); +}; + export const useTasks = () => { const client = useClient();