diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Rescan/RescanButton.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Rescan/RescanButton.tsx index 2e68a621..3cd6ff48 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Rescan/RescanButton.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Rescan/RescanButton.tsx @@ -8,7 +8,7 @@ type RescanResponse = { ok: boolean; counts: Record }; // Item query caches the header refreshes after a rescan (matches the queryKeys used by the // Automation and Capability pages). -const ITEM_QUERY_KEYS = ['tasks', 'skills', 'tools', 'processes']; +const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'processes', 'agents', 'agent-categories']; export function RescanButton() { const client = useClient(); @@ -25,7 +25,9 @@ export function RescanButton() { // you next open that page. This refreshes every item cache immediately, wherever you press it. await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key], refetchType: 'all' }))); const c = res.counts ?? {}; - toast.success(`Rescanned items — ${c.tasks ?? 0} tasks, ${c.tools ?? 0} tools, ${c.skills ?? 0} skills, ${c.processes ?? 0} processes`); + toast.success( + `Rescanned items — ${c.tasks ?? 0} tasks, ${c.agents ?? 0} agents, ${c.tools ?? 0} tools, ${c.skills ?? 0} skills, ${c.processes ?? 0} processes`, + ); } catch { toast.error('Failed to rescan items'); } finally { diff --git a/src/servers/api/items/rescan.ts b/src/servers/api/items/rescan.ts index 1dbfddcd..50ae2ce0 100644 --- a/src/servers/api/items/rescan.ts +++ b/src/servers/api/items/rescan.ts @@ -15,6 +15,7 @@ const CANONICAL: Record = { tasks: 'TASK.md', processes: 'PROCESS.md', extensions: 'index.ts', + agents: 'AGENT.md', }; function countItems(type: ItemType): number { diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx index f22bf1d5..3945ee43 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx @@ -3,6 +3,7 @@ import { Toolbar } from './components/Toolbar'; import { UploadProgress } from './components/UploadProgress'; import { FileViewContainer } from './components/FileViewContainer'; import { TaskRunnerDialog } from './components/TaskRunnerDialog'; +import { AgentRunnerDialog } from './components/AgentRunnerDialog'; import { VideoDownloadDialog } from './components/VideoDownloadDialog'; import { DictateDialog } from './components/DictateDialog'; import { useFileBrowserApp } from './useFileBrowserApp'; @@ -26,15 +27,13 @@ export const FileBrowserApp = ({ basePath = '/', rootOverride, initialPath, defa return (
- + +
); }; - diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/AgentRunnerDialog.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/AgentRunnerDialog.tsx new file mode 100644 index 00000000..36f98dea --- /dev/null +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/AgentRunnerDialog.tsx @@ -0,0 +1,27 @@ +import type { UseFileBrowserAppType } from '../useFileBrowserApp'; +import { AgentRunnerModal } from './AgentRunnerModal'; + +type AgentRunnerDialogProps = { + fileBrowserManager: UseFileBrowserAppType; +}; + +export const AgentRunnerDialog = ({ fileBrowserManager }: AgentRunnerDialogProps) => { + const { runningAgent, setRunningAgent, refresh, getEntryAbsPath } = fileBrowserManager; + + if (!runningAgent) return null; + + return ( + { + if (!open) { + setRunningAgent(null); + refresh(); + } + }} + agent={runningAgent.agent} + entryName={runningAgent.entry.name} + entryFullPath={getEntryAbsPath(runningAgent.entry.name)} + /> + ); +}; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/AgentRunnerModal.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/AgentRunnerModal.tsx new file mode 100644 index 00000000..2702d5c6 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/AgentRunnerModal.tsx @@ -0,0 +1,181 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router'; +import { X, Play, Bot, CircleCheck, CircleX, ExternalLink, Loader2 } from 'lucide-react'; +import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { cardStyle } from '@/components/Card'; +import { useClient } from 'hooks/useClient'; +import type { AgentSummary } from '../../useAgents'; +import { TaskInputForm, type TaskInputDef } from './TaskRunnerModal'; + +// Starting an agent is not running a job: the POST returns as soon as the session is spawned, and the +// run itself is watched in /chat. So this modal has no output pane — the transcript is the output, +// and it lives somewhere that survives closing this dialog. +type Phase = 'ready' | 'starting' | 'started' | 'failed'; + +type AgentDetail = { + description: string | null; + model: string; + inputs?: Record; +}; + +type StartResult = { sessionKey: string; cwd: string; model: string; chatUrl: string }; + +type AgentRunnerModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + agent: AgentSummary; + entryName: string; + /** Absolute on-disk path of the clicked entry — what the agent is pointed at. Never tilde form. */ + entryFullPath: string; +}; + +export const AgentRunnerModal = ({ open, onOpenChange, agent, entryName, entryFullPath }: AgentRunnerModalProps) => { + const client = useClient(); + const navigate = useNavigate(); + const [phase, setPhase] = useState('ready'); + const [detail, setDetail] = useState(null); + const [inputDefs, setInputDefs] = useState | null>(null); + const [formValues, setFormValues] = useState>({}); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + // Which inputs the file browser already answered — shown as the target row, not as editable fields. + const [autoFilledKeys, setAutoFilledKeys] = useState>(new Set()); + + useEffect(() => { + client.get(`/agents/${agent.dirName}`).then((data) => { + const defs = data.inputs ?? {}; + setDetail(data); + setInputDefs(defs); + + const initial: Record = {}; + const filled = new Set(); + for (const [key, def] of Object.entries(defs)) { + // entry_path is the only autofill the file browser can answer for an agent, and it answers it + // with the absolute path — tilde expansion is the server's job, never a prompt's. + if (def.autofill === 'entry_path') { + initial[key] = entryFullPath; + filled.add(key); + } else if (def.autofill === 'entry_name') { + initial[key] = entryName; + filled.add(key); + } else if (def.default !== undefined) { + initial[key] = def.default; + } + } + setFormValues(initial); + setAutoFilledKeys(filled); + }); + }, [agent.dirName]); + + const handleRun = async () => { + setPhase('starting'); + setError(null); + try { + const started = await client.post(`/agents/${agent.dirName}/run`, { inputs: formValues }); + setResult(started); + setPhase('started'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to start the agent'); + setPhase('failed'); + } + }; + + const openChat = () => { + if (!result) return; + onOpenChange(false); + navigate(result.chatUrl); + }; + + return ( + + + + ev.preventDefault()} + className="fixed left-[50%] top-[50%] z-[700] translate-x-[-50%] translate-y-[-50%] flex flex-col overflow-hidden rounded-xl border-2 border-duck-dark/30 shadow-2xl w-[90vw] max-w-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95" + style={cardStyle()} + > + {/* Header */} +
+
+ +
+ {agent.name} + {entryName} +
+ + + +
+ {detail?.description && ( +

{detail.description}

+ )} +
+ + {phase === 'started' && result ? ( +
+ +
+
Agent running
+
+ It keeps going whether or not this stays open. Watch it in the chat — newest session on top. +
+
+
+ + +
+
+ ) : ( + <> + {/* Target — the path the agent is pointed at, exactly as the server will receive it. */} +
+ Target + {entryFullPath} +
+ + {inputDefs && ( + setFormValues((prev) => ({ ...prev, [key]: value }))} + autoFilledKeys={autoFilledKeys} + /> + )} + + {error && ( +
+ + {error} +
+ )} + +
+ +
+ + )} +
+
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx index 16e6fc61..54acb2fe 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx @@ -68,7 +68,9 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { handleExtract, handlePlay, getMatchingTaskGroups, + getMatchingAgentGroups, handleRunTask, + handleRunAgent, handleCreateDashboard, fileScrollRef: scrollRef, setSearchQuery, @@ -246,6 +248,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { onPlay={handlePlay} taskGroups={getMatchingTaskGroups(entry.name, entry.type)} onRunTask={handleRunTask} + agentGroups={getMatchingAgentGroups(entry.name, entry.type)} + onRunAgent={handleRunAgent} onCreateDashboard={handleCreateDashboard} /> ); 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 ec11cf2b..02359821 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx @@ -15,6 +15,7 @@ import { FolderArchive, ClipboardCopy, Music, + Bot, } from 'lucide-react'; import { getIcon } from 'material-file-icons'; import { @@ -40,6 +41,7 @@ import { import { cardStyle } from '@/components/Card'; import type { DirEntry } from '../../../../hooks/useFilesAPI'; import type { TaskGroup, TaskSummary } from '../../useTasks'; +import type { AgentGroup, AgentSummary } from '../../useAgents'; import { getFileType } from '../../../FileViewer'; export type FileItemProps = { @@ -65,6 +67,8 @@ export type FileItemProps = { onPlay: (entry: DirEntry) => void; taskGroups: TaskGroup[]; onRunTask: (task: TaskSummary, entry: DirEntry) => void; + agentGroups: AgentGroup[]; + onRunAgent: (agent: AgentSummary, entry: DirEntry) => void; onCreateDashboard: (entry: DirEntry) => void; }; @@ -97,6 +101,8 @@ type MenuItemsProps = { onCopy: () => void; taskGroups: TaskGroup[]; onRunTask: (task: TaskSummary, entry: DirEntry) => void; + agentGroups: AgentGroup[]; + onRunAgent: (agent: AgentSummary, entry: DirEntry) => void; onCreateDashboard: (e: DirEntry) => void; }; @@ -115,6 +121,8 @@ const DropdownMenuItems = ({ onCopy, taskGroups, onRunTask, + agentGroups, + onRunAgent, onCreateDashboard, }: MenuItemsProps) => { const fileType = entry.type === 'file' ? getFileType(entry.name) : null; @@ -123,11 +131,14 @@ const DropdownMenuItems = ({ const showPlay = fileType === 'audio' || entry.type === 'directory'; const hasTasks = taskGroups.length > 0; - const hasActions = showPlay || showReadAloud || showExtract || hasTasks; + const hasAgents = agentGroups.length > 0; + const hasActions = showPlay || showReadAloud || showExtract || hasTasks || hasAgents; // Only nest when more than one category matched — a file usually matches a single category, and // Run Task > Video > Convert would just add a hop. const nestTasks = taskGroups.length > 1; const flatTasks = taskGroups[0]?.tasks ?? []; + const nestAgents = agentGroups.length > 1; + const flatAgents = agentGroups[0]?.agents ?? []; return ( <> @@ -185,6 +196,42 @@ const DropdownMenuItems = ({ )} + {hasAgents && ( + + + + Run Agent + + + {nestAgents + ? agentGroups.map((group) => ( + + {group.category} + + {group.agents.map((agent) => ( + onRunAgent(agent, entry)} + className="cursor-pointer" + > + {agent.name} + + ))} + + + )) + : flatAgents.map((agent) => ( + onRunAgent(agent, entry)} + className="cursor-pointer" + > + {agent.name} + + ))} + + + )} {hasActions && } @@ -243,6 +290,8 @@ const ContextMenuItems = ({ onCopy, taskGroups, onRunTask, + agentGroups, + onRunAgent, onCreateDashboard, }: MenuItemsProps) => { const fileType = entry.type === 'file' ? getFileType(entry.name) : null; @@ -251,11 +300,14 @@ const ContextMenuItems = ({ const showPlay = fileType === 'audio' || entry.type === 'directory'; const hasTasks = taskGroups.length > 0; - const hasActions = showPlay || showReadAloud || showExtract || hasTasks; + const hasAgents = agentGroups.length > 0; + const hasActions = showPlay || showReadAloud || showExtract || hasTasks || hasAgents; // Only nest when more than one category matched — a file usually matches a single category, and // Run Task > Video > Convert would just add a hop. const nestTasks = taskGroups.length > 1; const flatTasks = taskGroups[0]?.tasks ?? []; + const nestAgents = agentGroups.length > 1; + const flatAgents = agentGroups[0]?.agents ?? []; return ( <> @@ -309,6 +361,42 @@ const ContextMenuItems = ({ )} + {hasAgents && ( + + + + Run Agent + + + {nestAgents + ? agentGroups.map((group) => ( + + {group.category} + + {group.agents.map((agent) => ( + onRunAgent(agent, entry)} + className="cursor-pointer" + > + {agent.name} + + ))} + + + )) + : flatAgents.map((agent) => ( + onRunAgent(agent, entry)} + className="cursor-pointer" + > + {agent.name} + + ))} + + + )} {hasActions && } @@ -478,6 +566,8 @@ export const FileItem = ({ onPlay, taskGroups, onRunTask, + agentGroups, + onRunAgent, onCreateDashboard, }: FileItemProps) => { const [renaming, setRenaming] = useState(false); @@ -560,6 +650,8 @@ export const FileItem = ({ onCopy, taskGroups, onRunTask, + agentGroups, + onRunAgent, onCreateDashboard, }; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx index bb011790..1a24aee3 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx @@ -261,7 +261,9 @@ const AgenticTaskRunner = ({ // ── Task input definitions ── -type TaskInputDef = { +// Exported: the agent runner renders the same form for an agent's optional inputs. Agents never +// declare the media-probe input types, so those branches simply don't fire there. +export type TaskInputDef = { type: string; description?: string; default?: string; @@ -303,7 +305,7 @@ const parseSubtitleSpec = (raw?: string): SubtitleEditEntry[] => { } }; -const TaskInputForm = ({ +export const TaskInputForm = ({ inputDefs, values, onChange, diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index 56c56979..e9cdca34 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -3,6 +3,7 @@ import { useSearchParams, useNavigate } from 'react-router'; import { toast } from 'sonner'; import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI'; import { useTasks, type TaskSummary } from '../useTasks'; +import { useAgents, type AgentSummary } from '../useAgents'; import { useUserState } from 'state/useUserState'; import { useAuth } from 'hooks/useAuth'; import { usePanelChannel } from 'hooks/usePanelChannel'; @@ -54,10 +55,13 @@ export const useFileBrowserApp = ( entry: DirEntry; selectedNames?: string[]; } | null>(null); + // Agents are their own item type with their own modal — never folded into runningTask. + const [runningAgent, setRunningAgent] = useState<{ agent: AgentSummary; entry: DirEntry } | null>(null); const [showVideoDownload, setShowVideoDownload] = useState(false); const [showDictate, setShowDictate] = useState(false); const dragCounter = useRef(0); const { getMatchingTasks, getMatchingTaskGroups } = useTasks(); + const { getMatchingAgentGroups } = useAgents(); const searchTimerRef = useRef | null>(null); const searchInputRef = useRef(null); const fileScrollRef = useRef(null); @@ -396,6 +400,9 @@ export const useFileBrowserApp = ( setRunningTask({ task, entry, selectedNames: names.length > 1 ? names : undefined }); }; + // An agent takes exactly one target, so a multi-selection is not folded in the way a task's is. + const handleRunAgent = (agent: AgentSummary, entry: DirEntry) => setRunningAgent({ agent, entry }); + const handleCreateDashboard = (entry: DirEntry) => { const folderPath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`; navigate(`/dashboards/new?name=${encodeURIComponent(entry.name)}&cwd=${encodeURIComponent(folderPath)}`); @@ -663,6 +670,10 @@ export const useFileBrowserApp = ( setRunningTask, getMatchingTasks, getMatchingTaskGroups, + // Agent runner + runningAgent, + setRunningAgent, + getMatchingAgentGroups, // Files API (for self-contained dialogs like the video downloader) files, // Video download @@ -689,6 +700,7 @@ export const useFileBrowserApp = ( handleDownload, handleDownloadSelected, handleRunTask, + handleRunAgent, handleCreateDashboard, handleCreateDashboardHere, handleReadAloud, diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/useAgents.ts b/src/workspaces/officerdev/src/apps/FileBrowser/useAgents.ts new file mode 100644 index 00000000..2d461f06 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/FileBrowser/useAgents.ts @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query'; +import { useClient } from 'hooks/useClient'; +import { type TriggerConfig, groupByCategory, matchesTrigger } from './useTasks'; + +export type AgentSummary = { + dirName: string; + name: string; + description: string | null; + category: string | null; + triggers: TriggerConfig[]; +}; + +export type AgentGroup = { category: string; agents: AgentSummary[] }; + +// Agents share the task trigger/category vocabulary, but they are a separate item type and get their +// own submenu — an agent run is a chat session, not a job, so mixing the two menus would promise the +// wrong thing about what happens when you click. +export const useAgents = () => { + const client = useClient(); + + const { data: agents = [] } = useQuery({ + queryKey: ['agents'], + queryFn: () => client.get('/agents'), + staleTime: 60_000, + }); + + const { data: categoryOrder = [] } = useQuery({ + queryKey: ['agent-categories'], + queryFn: () => client.get('/agents/categories'), + staleTime: 60_000, + }); + + const getMatchingAgents = (fileName: string, entryType: 'file' | 'directory'): AgentSummary[] => + agents.filter((a) => matchesTrigger(a.triggers, fileName, entryType)); + + const getMatchingAgentGroups = (fileName: string, entryType: 'file' | 'directory'): AgentGroup[] => + groupByCategory(getMatchingAgents(fileName, entryType), categoryOrder).map(({ category, items }) => ({ + category, + agents: items, + })); + + return { agents, categoryOrder, getMatchingAgents, getMatchingAgentGroups }; +}; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts index 4d7ca1ab..9951cedd 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { useClient } from 'hooks/useClient'; -type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' }; +export type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' }; export type TaskSummary = { id: number; @@ -31,23 +31,43 @@ const categoryRank = (category: string, order: string[]): number => { return category === UNCATEGORIZED ? Number.MAX_SAFE_INTEGER : order.length; }; -export const groupTasksByCategory = (tasks: TaskSummary[], order: string[] = []): TaskGroup[] => { - const groups = new Map(); - for (const task of tasks) { - const category = task.category?.trim() || UNCATEGORIZED; +// Shared with agents, which surface in the same context menu under their own submenu and obey the +// same categories.yaml ordering. +export const groupByCategory = ( + items: T[], + order: string[] = [], +): Array<{ category: string; items: T[] }> => { + const groups = new Map(); + for (const item of items) { + const category = item.category?.trim() || UNCATEGORIZED; const bucket = groups.get(category); - if (bucket) bucket.push(task); - else groups.set(category, [task]); + if (bucket) bucket.push(item); + else groups.set(category, [item]); } return Array.from(groups.entries()) - .map(([category, list]) => ({ category, tasks: [...list].sort((a, b) => a.name.localeCompare(b.name)) })) + .map(([category, list]) => ({ category, items: [...list].sort((a, b) => a.name.localeCompare(b.name)) })) .sort( (a, b) => categoryRank(a.category, order) - categoryRank(b.category, order) || a.category.localeCompare(b.category), ); }; +export const groupTasksByCategory = (tasks: TaskSummary[], order: string[] = []): TaskGroup[] => + groupByCategory(tasks, order).map(({ category, items }) => ({ category, tasks: items })); + +/** Does this item's trigger list cover the clicked entry? Identical rule for tasks and agents. */ +export const matchesTrigger = ( + triggers: TriggerConfig[], + fileName: string, + entryType: 'file' | 'directory', +): boolean => { + if (entryType === 'directory') return triggers.some((tr) => tr.type === 'directory'); + const ext = fileName.split('.').pop()?.toLowerCase(); + if (!ext) return false; + return triggers.some((tr) => tr.type === 'file' && tr.extensions.includes(ext)); +}; + export const useTasks = () => { const client = useClient(); @@ -63,14 +83,8 @@ export const useTasks = () => { staleTime: 60_000, }); - const getMatchingTasks = (fileName: string, entryType: 'file' | 'directory'): TaskSummary[] => { - if (entryType === 'directory') { - return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'directory')); - } - const ext = fileName.split('.').pop()?.toLowerCase(); - if (!ext) return []; - return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'file' && tr.extensions.includes(ext))); - }; + const getMatchingTasks = (fileName: string, entryType: 'file' | 'directory'): TaskSummary[] => + tasks.filter((t) => matchesTrigger(t.triggers, fileName, entryType)); // Grouped for the context menu, ordered by categories.yaml. const getMatchingTaskGroups = (fileName: string, entryType: 'file' | 'directory'): TaskGroup[] =>