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:
@@ -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
|
||||
// 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 {
|
||||
|
||||
@@ -15,6 +15,7 @@ const CANONICAL: Record<ItemType, string> = {
|
||||
tasks: 'TASK.md',
|
||||
processes: 'PROCESS.md',
|
||||
extensions: 'index.ts',
|
||||
agents: 'AGENT.md',
|
||||
};
|
||||
|
||||
function countItems(type: ItemType): number {
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<Toolbar fileBrowserManager={fileBrowserManager} />
|
||||
<Breadcrumb
|
||||
path={fileBrowserManager.currentPath}
|
||||
onNavigate={handleNavigate} basePath={basePath} />
|
||||
<Breadcrumb path={fileBrowserManager.currentPath} onNavigate={handleNavigate} basePath={basePath} />
|
||||
<UploadProgress fileBrowserManager={fileBrowserManager} />
|
||||
<FileViewContainer fileBrowserManager={fileBrowserManager} />
|
||||
<TaskRunnerDialog fileBrowserManager={fileBrowserManager} />
|
||||
<AgentRunnerDialog fileBrowserManager={fileBrowserManager} />
|
||||
<VideoDownloadDialog fileBrowserManager={fileBrowserManager} />
|
||||
<DictateDialog fileBrowserManager={fileBrowserManager} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+27
@@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+181
@@ -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,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
+94
-2
@@ -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 = ({
|
||||
</DropdownMenuSubContent>
|
||||
</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 />}
|
||||
<DropdownMenuItem onClick={onCut} className="cursor-pointer">
|
||||
<Scissors className="mr-2 h-4 w-4" />
|
||||
@@ -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 = ({
|
||||
</ContextMenuSubContent>
|
||||
</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 />}
|
||||
<ContextMenuItem onClick={onCut} className="cursor-pointer">
|
||||
<Scissors className="mr-2 h-4 w-4" />
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const fileScrollRef = useRef<HTMLDivElement | null>(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,
|
||||
|
||||
@@ -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 { 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<string, TaskSummary[]>();
|
||||
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 = <T extends { category: string | null; name: string }>(
|
||||
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);
|
||||
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[] =>
|
||||
|
||||
Reference in New Issue
Block a user