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
|
// 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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+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,
|
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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
+94
-2
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -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[] =>
|
||||||
|
|||||||
Reference in New Issue
Block a user