run an agent from the file browser

Right-click an entry and agents whose triggers match appear under their own Run Agent
submenu — deliberately not folded in with tasks, because an agent run is a chat session and
not a job, and one menu promising both would lie about what a click does. The modal shows the
absolute target path, autofills entry_path with it (tilde expansion stays the server's job),
and on Run links to /chat?cwd=<runs dir> instead of a queue entry: there is no job row to view.

Trigger matching and category grouping are now shared with tasks rather than duplicated, and
the task input form is reused as-is.

Rescan counts agents and invalidates their caches, so a new AGENT.md shows up on the button
rather than after the 60s staleTime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 00:28:51 +00:00
co-authored by Claude Opus 5
parent 6b46d9f1f9
commit cc95f7fa17
11 changed files with 403 additions and 26 deletions
@@ -8,7 +8,7 @@ type RescanResponse = { ok: boolean; counts: Record<string, number> };
// Item query caches the header refreshes after a rescan (matches the queryKeys used by the // Item query caches the header refreshes after a rescan (matches the queryKeys used by the
// Automation and Capability pages). // 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() { export function RescanButton() {
const client = useClient(); 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. // 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' }))); await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key], refetchType: 'all' })));
const c = res.counts ?? {}; 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 { } catch {
toast.error('Failed to rescan items'); toast.error('Failed to rescan items');
} finally { } finally {
+1
View File
@@ -15,6 +15,7 @@ const CANONICAL: Record<ItemType, string> = {
tasks: 'TASK.md', tasks: 'TASK.md',
processes: 'PROCESS.md', processes: 'PROCESS.md',
extensions: 'index.ts', extensions: 'index.ts',
agents: 'AGENT.md',
}; };
function countItems(type: ItemType): number { function countItems(type: ItemType): number {
@@ -3,6 +3,7 @@ import { Toolbar } from './components/Toolbar';
import { UploadProgress } from './components/UploadProgress'; import { UploadProgress } from './components/UploadProgress';
import { FileViewContainer } from './components/FileViewContainer'; import { FileViewContainer } from './components/FileViewContainer';
import { TaskRunnerDialog } from './components/TaskRunnerDialog'; import { TaskRunnerDialog } from './components/TaskRunnerDialog';
import { AgentRunnerDialog } from './components/AgentRunnerDialog';
import { VideoDownloadDialog } from './components/VideoDownloadDialog'; import { VideoDownloadDialog } from './components/VideoDownloadDialog';
import { DictateDialog } from './components/DictateDialog'; import { DictateDialog } from './components/DictateDialog';
import { useFileBrowserApp } from './useFileBrowserApp'; import { useFileBrowserApp } from './useFileBrowserApp';
@@ -26,15 +27,13 @@ export const FileBrowserApp = ({ basePath = '/', rootOverride, initialPath, defa
return ( return (
<div className="flex flex-col h-full overflow-hidden"> <div className="flex flex-col h-full overflow-hidden">
<Toolbar fileBrowserManager={fileBrowserManager} /> <Toolbar fileBrowserManager={fileBrowserManager} />
<Breadcrumb <Breadcrumb path={fileBrowserManager.currentPath} onNavigate={handleNavigate} basePath={basePath} />
path={fileBrowserManager.currentPath}
onNavigate={handleNavigate} basePath={basePath} />
<UploadProgress fileBrowserManager={fileBrowserManager} /> <UploadProgress fileBrowserManager={fileBrowserManager} />
<FileViewContainer fileBrowserManager={fileBrowserManager} /> <FileViewContainer fileBrowserManager={fileBrowserManager} />
<TaskRunnerDialog fileBrowserManager={fileBrowserManager} /> <TaskRunnerDialog fileBrowserManager={fileBrowserManager} />
<AgentRunnerDialog fileBrowserManager={fileBrowserManager} />
<VideoDownloadDialog fileBrowserManager={fileBrowserManager} /> <VideoDownloadDialog fileBrowserManager={fileBrowserManager} />
<DictateDialog fileBrowserManager={fileBrowserManager} /> <DictateDialog fileBrowserManager={fileBrowserManager} />
</div> </div>
); );
}; };
@@ -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 (
<AgentRunnerModal
open
onOpenChange={(open) => {
if (!open) {
setRunningAgent(null);
refresh();
}
}}
agent={runningAgent.agent}
entryName={runningAgent.entry.name}
entryFullPath={getEntryAbsPath(runningAgent.entry.name)}
/>
);
};
@@ -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<string, TaskInputDef>;
};
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<Phase>('ready');
const [detail, setDetail] = useState<AgentDetail | null>(null);
const [inputDefs, setInputDefs] = useState<Record<string, TaskInputDef> | null>(null);
const [formValues, setFormValues] = useState<Record<string, string>>({});
const [result, setResult] = useState<StartResult | null>(null);
const [error, setError] = useState<string | null>(null);
// Which inputs the file browser already answered — shown as the target row, not as editable fields.
const [autoFilledKeys, setAutoFilledKeys] = useState<Set<string>>(new Set());
useEffect(() => {
client.get<AgentDetail>(`/agents/${agent.dirName}`).then((data) => {
const defs = data.inputs ?? {};
setDetail(data);
setInputDefs(defs);
const initial: Record<string, string> = {};
const filled = new Set<string>();
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<StartResult>(`/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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogPortal>
<DialogOverlay className="z-[700] bg-black/60 backdrop-blur-sm" />
<DialogPrimitive.Content
onOpenAutoFocus={(ev) => 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 */}
<div className="shrink-0 flex flex-col border-b border-duck-dark/10 bg-background/60">
<div className="flex items-center gap-3 px-5 py-3">
<Bot className="h-4 w-4 shrink-0 text-duck-teal" />
<div className="flex-1 min-w-0">
<span className="text-sm font-semibold text-duck-dark truncate block">{agent.name}</span>
<span className="text-xs text-duck-dark/50 truncate block">{entryName}</span>
</div>
<DialogPrimitive.Close className="p-1.5 rounded-md text-duck-dark/40 hover:text-duck-dark hover:bg-duck-dark/5 transition-colors cursor-pointer">
<X className="h-4 w-4" />
</DialogPrimitive.Close>
</div>
{detail?.description && (
<p className="px-5 pb-3 text-xs text-duck-dark/50 dark:text-foreground/50 -mt-1">{detail.description}</p>
)}
</div>
{phase === 'started' && result ? (
<div className="flex flex-col items-center justify-center gap-4 p-8 text-center">
<CircleCheck className="h-10 w-10 text-duck-teal" />
<div>
<div className="text-base font-medium text-duck-dark dark:text-foreground">Agent running</div>
<div className="text-sm text-duck-dark/50 dark:text-foreground/50 mt-1">
It keeps going whether or not this stays open. Watch it in the chat newest session on top.
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={openChat}
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-duck-teal text-white text-sm font-medium hover:bg-duck-teal/90 cursor-pointer"
>
<ExternalLink className="h-4 w-4" /> Open chat
</button>
<button
onClick={() => onOpenChange(false)}
className="px-5 py-2 rounded-lg text-sm font-medium text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 cursor-pointer"
>
Close
</button>
</div>
</div>
) : (
<>
{/* Target — the path the agent is pointed at, exactly as the server will receive it. */}
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-1">
<span className="text-xs font-medium text-duck-dark/70 dark:text-foreground/70">Target</span>
<span className="text-xs font-mono break-all text-duck-dark dark:text-foreground">{entryFullPath}</span>
</div>
{inputDefs && (
<TaskInputForm
inputDefs={inputDefs}
values={formValues}
onChange={(key, value) => setFormValues((prev) => ({ ...prev, [key]: value }))}
autoFilledKeys={autoFilledKeys}
/>
)}
{error && (
<div className="px-5 py-3 flex items-center gap-2 text-sm text-red-500">
<CircleX className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<div className="shrink-0 flex items-center justify-center gap-3 border-t border-duck-dark/10 py-4">
<button
onClick={handleRun}
disabled={!inputDefs || phase === 'starting'}
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{phase === 'starting' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
{phase === 'starting' ? 'Starting…' : 'Run agent'}
</button>
</div>
</>
)}
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
);
};
@@ -68,7 +68,9 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
handleExtract, handleExtract,
handlePlay, handlePlay,
getMatchingTaskGroups, getMatchingTaskGroups,
getMatchingAgentGroups,
handleRunTask, handleRunTask,
handleRunAgent,
handleCreateDashboard, handleCreateDashboard,
fileScrollRef: scrollRef, fileScrollRef: scrollRef,
setSearchQuery, setSearchQuery,
@@ -246,6 +248,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
onPlay={handlePlay} onPlay={handlePlay}
taskGroups={getMatchingTaskGroups(entry.name, entry.type)} taskGroups={getMatchingTaskGroups(entry.name, entry.type)}
onRunTask={handleRunTask} onRunTask={handleRunTask}
agentGroups={getMatchingAgentGroups(entry.name, entry.type)}
onRunAgent={handleRunAgent}
onCreateDashboard={handleCreateDashboard} onCreateDashboard={handleCreateDashboard}
/> />
); );
@@ -15,6 +15,7 @@ import {
FolderArchive, FolderArchive,
ClipboardCopy, ClipboardCopy,
Music, Music,
Bot,
} from 'lucide-react'; } from 'lucide-react';
import { getIcon } from 'material-file-icons'; import { getIcon } from 'material-file-icons';
import { import {
@@ -40,6 +41,7 @@ import {
import { cardStyle } from '@/components/Card'; import { cardStyle } from '@/components/Card';
import type { DirEntry } from '../../../../hooks/useFilesAPI'; import type { DirEntry } from '../../../../hooks/useFilesAPI';
import type { TaskGroup, TaskSummary } from '../../useTasks'; import type { TaskGroup, TaskSummary } from '../../useTasks';
import type { AgentGroup, AgentSummary } from '../../useAgents';
import { getFileType } from '../../../FileViewer'; import { getFileType } from '../../../FileViewer';
export type FileItemProps = { export type FileItemProps = {
@@ -65,6 +67,8 @@ export type FileItemProps = {
onPlay: (entry: DirEntry) => void; onPlay: (entry: DirEntry) => void;
taskGroups: TaskGroup[]; taskGroups: TaskGroup[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void; onRunTask: (task: TaskSummary, entry: DirEntry) => void;
agentGroups: AgentGroup[];
onRunAgent: (agent: AgentSummary, entry: DirEntry) => void;
onCreateDashboard: (entry: DirEntry) => void; onCreateDashboard: (entry: DirEntry) => void;
}; };
@@ -97,6 +101,8 @@ type MenuItemsProps = {
onCopy: () => void; onCopy: () => void;
taskGroups: TaskGroup[]; taskGroups: TaskGroup[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void; onRunTask: (task: TaskSummary, entry: DirEntry) => void;
agentGroups: AgentGroup[];
onRunAgent: (agent: AgentSummary, entry: DirEntry) => void;
onCreateDashboard: (e: DirEntry) => void; onCreateDashboard: (e: DirEntry) => void;
}; };
@@ -115,6 +121,8 @@ const DropdownMenuItems = ({
onCopy, onCopy,
taskGroups, taskGroups,
onRunTask, onRunTask,
agentGroups,
onRunAgent,
onCreateDashboard, onCreateDashboard,
}: MenuItemsProps) => { }: MenuItemsProps) => {
const fileType = entry.type === 'file' ? getFileType(entry.name) : null; const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
@@ -123,11 +131,14 @@ const DropdownMenuItems = ({
const showPlay = fileType === 'audio' || entry.type === 'directory'; const showPlay = fileType === 'audio' || entry.type === 'directory';
const hasTasks = taskGroups.length > 0; 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 // 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. // Run Task > Video > Convert would just add a hop.
const nestTasks = taskGroups.length > 1; const nestTasks = taskGroups.length > 1;
const flatTasks = taskGroups[0]?.tasks ?? []; const flatTasks = taskGroups[0]?.tasks ?? [];
const nestAgents = agentGroups.length > 1;
const flatAgents = agentGroups[0]?.agents ?? [];
return ( return (
<> <>
@@ -185,6 +196,42 @@ const DropdownMenuItems = ({
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
)} )}
{hasAgents && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<Bot className="mr-2 h-4 w-4" />
Run Agent
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="z-[600]">
{nestAgents
? agentGroups.map((group) => (
<DropdownMenuSub key={group.category}>
<DropdownMenuSubTrigger className="cursor-pointer">{group.category}</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="z-[600]">
{group.agents.map((agent) => (
<DropdownMenuItem
key={agent.dirName}
onClick={() => onRunAgent(agent, entry)}
className="cursor-pointer"
>
{agent.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
))
: flatAgents.map((agent) => (
<DropdownMenuItem
key={agent.dirName}
onClick={() => onRunAgent(agent, entry)}
className="cursor-pointer"
>
{agent.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{hasActions && <DropdownMenuSeparator />} {hasActions && <DropdownMenuSeparator />}
<DropdownMenuItem onClick={onCut} className="cursor-pointer"> <DropdownMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" /> <Scissors className="mr-2 h-4 w-4" />
@@ -243,6 +290,8 @@ const ContextMenuItems = ({
onCopy, onCopy,
taskGroups, taskGroups,
onRunTask, onRunTask,
agentGroups,
onRunAgent,
onCreateDashboard, onCreateDashboard,
}: MenuItemsProps) => { }: MenuItemsProps) => {
const fileType = entry.type === 'file' ? getFileType(entry.name) : null; const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
@@ -251,11 +300,14 @@ const ContextMenuItems = ({
const showPlay = fileType === 'audio' || entry.type === 'directory'; const showPlay = fileType === 'audio' || entry.type === 'directory';
const hasTasks = taskGroups.length > 0; 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 // 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. // Run Task > Video > Convert would just add a hop.
const nestTasks = taskGroups.length > 1; const nestTasks = taskGroups.length > 1;
const flatTasks = taskGroups[0]?.tasks ?? []; const flatTasks = taskGroups[0]?.tasks ?? [];
const nestAgents = agentGroups.length > 1;
const flatAgents = agentGroups[0]?.agents ?? [];
return ( return (
<> <>
@@ -309,6 +361,42 @@ const ContextMenuItems = ({
</ContextMenuSubContent> </ContextMenuSubContent>
</ContextMenuSub> </ContextMenuSub>
)} )}
{hasAgents && (
<ContextMenuSub>
<ContextMenuSubTrigger className="cursor-pointer">
<Bot className="mr-2 h-4 w-4" />
Run Agent
</ContextMenuSubTrigger>
<ContextMenuSubContent className="z-[600]">
{nestAgents
? agentGroups.map((group) => (
<ContextMenuSub key={group.category}>
<ContextMenuSubTrigger className="cursor-pointer">{group.category}</ContextMenuSubTrigger>
<ContextMenuSubContent className="z-[600]">
{group.agents.map((agent) => (
<ContextMenuItem
key={agent.dirName}
onClick={() => onRunAgent(agent, entry)}
className="cursor-pointer"
>
{agent.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
))
: flatAgents.map((agent) => (
<ContextMenuItem
key={agent.dirName}
onClick={() => onRunAgent(agent, entry)}
className="cursor-pointer"
>
{agent.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
)}
{hasActions && <ContextMenuSeparator />} {hasActions && <ContextMenuSeparator />}
<ContextMenuItem onClick={onCut} className="cursor-pointer"> <ContextMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" /> <Scissors className="mr-2 h-4 w-4" />
@@ -478,6 +566,8 @@ export const FileItem = ({
onPlay, onPlay,
taskGroups, taskGroups,
onRunTask, onRunTask,
agentGroups,
onRunAgent,
onCreateDashboard, onCreateDashboard,
}: FileItemProps) => { }: FileItemProps) => {
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
@@ -560,6 +650,8 @@ export const FileItem = ({
onCopy, onCopy,
taskGroups, taskGroups,
onRunTask, onRunTask,
agentGroups,
onRunAgent,
onCreateDashboard, onCreateDashboard,
}; };
@@ -261,7 +261,9 @@ const AgenticTaskRunner = ({
// ── Task input definitions ── // ── 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; type: string;
description?: string; description?: string;
default?: string; default?: string;
@@ -303,7 +305,7 @@ const parseSubtitleSpec = (raw?: string): SubtitleEditEntry[] => {
} }
}; };
const TaskInputForm = ({ export const TaskInputForm = ({
inputDefs, inputDefs,
values, values,
onChange, onChange,
@@ -3,6 +3,7 @@ import { useSearchParams, useNavigate } from 'react-router';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI'; import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI';
import { useTasks, type TaskSummary } from '../useTasks'; import { useTasks, type TaskSummary } from '../useTasks';
import { useAgents, type AgentSummary } from '../useAgents';
import { useUserState } from 'state/useUserState'; import { useUserState } from 'state/useUserState';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
@@ -54,10 +55,13 @@ export const useFileBrowserApp = (
entry: DirEntry; entry: DirEntry;
selectedNames?: string[]; selectedNames?: string[];
} | null>(null); } | 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 [showVideoDownload, setShowVideoDownload] = useState(false);
const [showDictate, setShowDictate] = useState(false); const [showDictate, setShowDictate] = useState(false);
const dragCounter = useRef(0); const dragCounter = useRef(0);
const { getMatchingTasks, getMatchingTaskGroups } = useTasks(); const { getMatchingTasks, getMatchingTaskGroups } = useTasks();
const { getMatchingAgentGroups } = useAgents();
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null); const searchInputRef = useRef<HTMLInputElement | null>(null);
const fileScrollRef = useRef<HTMLDivElement | null>(null); const fileScrollRef = useRef<HTMLDivElement | null>(null);
@@ -396,6 +400,9 @@ export const useFileBrowserApp = (
setRunningTask({ task, entry, selectedNames: names.length > 1 ? names : undefined }); 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 handleCreateDashboard = (entry: DirEntry) => {
const folderPath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`; const folderPath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
navigate(`/dashboards/new?name=${encodeURIComponent(entry.name)}&cwd=${encodeURIComponent(folderPath)}`); navigate(`/dashboards/new?name=${encodeURIComponent(entry.name)}&cwd=${encodeURIComponent(folderPath)}`);
@@ -663,6 +670,10 @@ export const useFileBrowserApp = (
setRunningTask, setRunningTask,
getMatchingTasks, getMatchingTasks,
getMatchingTaskGroups, getMatchingTaskGroups,
// Agent runner
runningAgent,
setRunningAgent,
getMatchingAgentGroups,
// Files API (for self-contained dialogs like the video downloader) // Files API (for self-contained dialogs like the video downloader)
files, files,
// Video download // Video download
@@ -689,6 +700,7 @@ export const useFileBrowserApp = (
handleDownload, handleDownload,
handleDownloadSelected, handleDownloadSelected,
handleRunTask, handleRunTask,
handleRunAgent,
handleCreateDashboard, handleCreateDashboard,
handleCreateDashboardHere, handleCreateDashboardHere,
handleReadAloud, handleReadAloud,
@@ -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<AgentSummary[]>({
queryKey: ['agents'],
queryFn: () => client.get('/agents'),
staleTime: 60_000,
});
const { data: categoryOrder = [] } = useQuery<string[]>({
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 };
};
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient'; 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 = { export type TaskSummary = {
id: number; id: number;
@@ -31,23 +31,43 @@ const categoryRank = (category: string, order: string[]): number => {
return category === UNCATEGORIZED ? Number.MAX_SAFE_INTEGER : order.length; return category === UNCATEGORIZED ? Number.MAX_SAFE_INTEGER : order.length;
}; };
export const groupTasksByCategory = (tasks: TaskSummary[], order: string[] = []): TaskGroup[] => { // Shared with agents, which surface in the same context menu under their own submenu and obey the
const groups = new Map<string, TaskSummary[]>(); // same categories.yaml ordering.
for (const task of tasks) { export const groupByCategory = <T extends { category: string | null; name: string }>(
const category = task.category?.trim() || UNCATEGORIZED; items: T[],
order: string[] = [],
): Array<{ category: string; items: T[] }> => {
const groups = new Map<string, T[]>();
for (const item of items) {
const category = item.category?.trim() || UNCATEGORIZED;
const bucket = groups.get(category); const bucket = groups.get(category);
if (bucket) bucket.push(task); if (bucket) bucket.push(item);
else groups.set(category, [task]); else groups.set(category, [item]);
} }
return Array.from(groups.entries()) 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( .sort(
(a, b) => (a, b) =>
categoryRank(a.category, order) - categoryRank(b.category, order) || a.category.localeCompare(b.category), 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 = () => { export const useTasks = () => {
const client = useClient(); const client = useClient();
@@ -63,14 +83,8 @@ export const useTasks = () => {
staleTime: 60_000, staleTime: 60_000,
}); });
const getMatchingTasks = (fileName: string, entryType: 'file' | 'directory'): TaskSummary[] => { const getMatchingTasks = (fileName: string, entryType: 'file' | 'directory'): TaskSummary[] =>
if (entryType === 'directory') { tasks.filter((t) => matchesTrigger(t.triggers, fileName, entryType));
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)));
};
// Grouped for the context menu, ordered by categories.yaml. // Grouped for the context menu, ordered by categories.yaml.
const getMatchingTaskGroups = (fileName: string, entryType: 'file' | 'directory'): TaskGroup[] => const getMatchingTaskGroups = (fileName: string, entryType: 'file' | 'directory'): TaskGroup[] =>