add pipeline task system, agentic music tasks, copy buttons, and pi-bridge cleanup

- Pipeline mode: new task mode that chains agentic tasks sequentially with
  foreach/subdirectory iteration and skip_if conditions
- Pipeline executor backend (WebSocket at /api/tasks/pipeline/ws) with
  support for both Pi and Claude Code models
- Frontend PipelineRunner component with step progress, streaming output,
  and aggregate cost tracking
- New agentic tasks: prepare-discography, fetch-album-info, build-discography
  (pipeline combining both)
- Seed parser extended to handle pipeline steps in frontmatter config
- CopyButton component added to assistant bubbles, error bubbles, and
  tool input/output sections
- Removed obsolete SearXNG/Apify/browser relay code from pi-bridge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 20:20:20 +00:00
co-authored by Claude Opus 4.6
parent 3993c7d1c7
commit 22c60d4a57
15 changed files with 1158 additions and 62 deletions
@@ -0,0 +1,28 @@
import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
type CopyButtonProps = {
text: string;
className?: string;
};
export const CopyButton = ({ text, className = '' }: CopyButtonProps) => {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text.trim());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
type="button"
onClick={handleCopy}
className={`p-1 rounded text-duck-dark dark:text-white opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity cursor-pointer ${className}`}
title="Copy"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
};
@@ -9,6 +9,7 @@ import { ToolActivity } from './ToolActivity';
import { QuestionActivity } from './QuestionActivity';
import { getRawUrl } from '../../FileViewer/file-types';
import { useFilesAPI } from '../../../hooks/useFilesAPI';
import { CopyButton } from './CopyButton';
const sanitizeSchema = {
...defaultSchema,
@@ -135,7 +136,7 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
const assistantText = typeof message.text === 'string' ? message.text : '';
if (!assistantText) return null;
return (
<div className="flex justify-start">
<div className="flex justify-start group">
<div className="max-w-[85%]">
<div className="rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
<div className="chat-md">
@@ -143,7 +144,8 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
{injectImages(assistantText)}
</ReactMarkdown>
</div>
<div className="flex justify-end -mb-1 -mr-1">
<div className="flex justify-end -mb-1 -mr-1 gap-0.5">
<CopyButton text={assistantText} />
<ReadAloudButton id={message.id ?? crypto.randomUUID()} text={assistantText} />
</div>
</div>
@@ -169,9 +171,12 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
case 'error':
return (
<div className="flex justify-start">
<div className="max-w-[80%] rounded-2xl bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 px-4 py-2.5 text-sm text-red-700 dark:text-red-300">
<div className="flex justify-start group">
<div className="relative max-w-[80%] rounded-2xl bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 px-4 py-2.5 text-sm text-red-700 dark:text-red-300">
{message.text}
<div className="flex justify-end -mb-1 -mr-1">
<CopyButton text={message.text} />
</div>
</div>
</div>
);
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react';
import type { ChatMessage } from '../types';
import { CopyButton } from './CopyButton';
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
@@ -74,8 +75,16 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
{open && (
<div className="ml-7 mt-1 space-y-2 text-xs">
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto">
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Input</div>
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto group/input">
<div className="flex items-center justify-between mb-1">
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider">Input</div>
<CopyButton
text={message.toolName === 'Bash'
? (message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2)
: Object.entries(message.toolInput).map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`).join('\n')}
className="!opacity-0 group-hover/input:!opacity-60 hover:!opacity-100"
/>
</div>
{message.toolName === 'Bash' ? (
<pre className="bg-gray-900 text-green-400 p-2 rounded font-mono whitespace-pre-wrap break-all">
{(message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2)}
@@ -90,8 +99,11 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
</div>
{message.output !== undefined && (
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto">
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Output</div>
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto group/output">
<div className="flex items-center justify-between mb-1">
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider">Output</div>
<CopyButton text={message.output} className="!opacity-0 group-hover/output:!opacity-60 hover:!opacity-100" />
</div>
<ToolOutput toolName={message.toolName} output={message.output} isError={isError} />
</div>
)}
@@ -10,6 +10,7 @@ import { useUserVisibleModels } from 'state/useModels';
import { useClient } from 'hooks/useClient';
import type { TaskSummary } from '../../useTasks';
import { useTaskRunner } from './useTaskRunner';
import { usePipelineRunner } from './usePipelineRunner';
const playDing = () => {
const ctx = new AudioContext();
@@ -38,17 +39,43 @@ const playDing = () => {
type Phase = 'ready' | 'running' | 'done';
type PiMonoInnerProps = {
taskDirName: string;
defaultInput: string;
cwd: { root?: string; path: string };
initialModel: string | null;
taskInfo: TaskInfo;
sandboxed?: boolean;
context: Record<string, string>;
};
const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: PiMonoInnerProps) => {
const PiMonoInner = ({ taskDirName, defaultInput, cwd, initialModel, taskInfo, sandboxed, context }: PiMonoInnerProps) => {
const [phase, setPhase] = useState<Phase>('ready');
const chat = usePiChat(undefined, initialModel, { replaceUrl: false, taskInfo });
const availableModels = useUserVisibleModels();
const client = useClient();
const [inputDefs, setInputDefs] = useState<Record<string, TaskInputDef> | null>(null);
const [formValues, setFormValues] = useState<Record<string, string>>({});
const [taskBody, setTaskBody] = useState<string | null>(null);
// Fetch task detail for inputs and body
useEffect(() => {
client.get<{ inputs?: Record<string, TaskInputDef>; body?: string }>(`/tasks/${taskDirName}`).then((task) => {
const defs = task.inputs ?? {};
setInputDefs(defs);
setTaskBody(task.body ?? null);
// Initialize from defaults and autofill
const initial: Record<string, string> = {};
for (const [key, def] of Object.entries(defs)) {
if (def.autofill && context[def.autofill]) initial[key] = context[def.autofill]!;
else if (def.default !== undefined) initial[key] = def.default;
}
setFormValues(initial);
});
}, [taskDirName]);
const handleInputChange = (key: string, value: string) => {
setFormValues((prev) => ({ ...prev, [key]: value }));
};
// --- Independent message accumulator (never loses messages) ---
const accRef = useRef<ChatMessage[]>([]);
@@ -114,17 +141,42 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: P
}, [chat.isGenerating]);
const handleRun = () => {
// Build prompt from task body + input values, or fall back to generic prompt
let prompt = defaultInput;
if (taskBody && inputDefs) {
const inputLines = Object.entries(formValues)
.filter(([, v]) => v.trim())
.map(([key, value]) => {
const label = inputDefs[key]?.description ?? key;
return `- **${label}**: ${value}`;
})
.join('\n');
const contextLines: string[] = [];
if (context.entry_path) contextLines.push(`- **Target directory**: ${context.entry_path}`);
const contextSection = contextLines.length > 0 ? `\n\n## Context\n\n${contextLines.join('\n')}` : '';
prompt = `${taskBody.trim()}\n\n## Inputs\n\n${inputLines}${contextSection}`;
}
setPhase('running');
chat.sendPrompt(defaultInput, undefined, undefined, cwd, undefined, sandboxed);
chat.sendPrompt(prompt, undefined, undefined, cwd, undefined, sandboxed);
};
const hasConfigurableInputs = inputDefs && Object.keys(inputDefs).length > 0;
if (phase === 'ready') {
return (
<>
{hasConfigurableInputs && (
<TaskInputForm
inputDefs={inputDefs}
values={formValues}
onChange={handleInputChange}
autoFilledKeys={new Set()}
/>
)}
<div className="flex-1 flex items-center justify-center">
<button
onClick={handleRun}
disabled={!chat.isConnected}
disabled={!chat.isConnected || !inputDefs}
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"
>
<Play className="h-4 w-4" />
@@ -199,6 +251,7 @@ type TaskInputDef = {
description?: string;
default?: string;
options?: string[];
autofill?: string;
};
type TaskInputFormProps = {
@@ -282,10 +335,11 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys }: TaskInpu
type ScriptRunnerProps = {
taskDirName: string;
autoInputs: Record<string, string>;
context: Record<string, string>;
cwd?: string;
};
const ScriptRunner = ({ taskDirName, autoInputs, cwd }: ScriptRunnerProps) => {
const ScriptRunner = ({ taskDirName, autoInputs, context, cwd }: ScriptRunnerProps) => {
const runner = useTaskRunner();
const client = useClient();
const bottomRef = useRef<HTMLDivElement | null>(null);
@@ -297,12 +351,13 @@ const ScriptRunner = ({ taskDirName, autoInputs, cwd }: ScriptRunnerProps) => {
client.get<{ inputs?: Record<string, TaskInputDef> }>(`/tasks/${taskDirName}`).then((task) => {
const defs = task.inputs ?? {};
setInputDefs(defs);
// Initialize form values from defaults
const defaults: Record<string, string> = {};
// Initialize from autofill context, then defaults
const initial: Record<string, string> = {};
for (const [key, def] of Object.entries(defs)) {
if (def.default !== undefined) defaults[key] = def.default;
if (def.autofill && context[def.autofill]) initial[key] = context[def.autofill]!;
else if (def.default !== undefined) initial[key] = def.default;
}
setFormValues(defaults);
setFormValues(initial);
});
}, [taskDirName]);
@@ -421,6 +476,158 @@ const ScriptRunner = ({ taskDirName, autoInputs, cwd }: ScriptRunnerProps) => {
);
};
// ── Pipeline-mode runner ──
type PipelineRunnerProps = {
taskDirName: string;
context: Record<string, string>;
cwd?: string;
};
const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
const pipeline = usePipelineRunner();
const client = useClient();
const bottomRef = useRef<HTMLDivElement | null>(null);
const [inputDefs, setInputDefs] = useState<Record<string, TaskInputDef> | null>(null);
const [formValues, setFormValues] = useState<Record<string, string>>({});
// Fetch task detail for inputs
useEffect(() => {
client.get<{ inputs?: Record<string, TaskInputDef> }>(`/tasks/${taskDirName}`).then((task) => {
const defs = task.inputs ?? {};
setInputDefs(defs);
const initial: Record<string, string> = {};
for (const [key, def] of Object.entries(defs)) {
if (def.autofill && context[def.autofill]) initial[key] = context[def.autofill]!;
else if (def.default !== undefined) initial[key] = def.default;
}
setFormValues(initial);
});
}, [taskDirName]);
const handleInputChange = (key: string, value: string) => {
setFormValues((prev) => ({ ...prev, [key]: value }));
};
// Auto-scroll
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [pipeline.messages, pipeline.streamingText, pipeline.currentStep]);
// Ding on completion
const prevPhaseRef = useRef(pipeline.phase);
useEffect(() => {
if (prevPhaseRef.current === 'running' && pipeline.phase === 'done') {
playDing();
}
prevPhaseRef.current = pipeline.phase;
}, [pipeline.phase]);
const handleRun = () => {
pipeline.run(taskDirName, formValues, cwd);
};
const hasConfigurableInputs = inputDefs && Object.keys(inputDefs).length > 0;
if (pipeline.phase === 'ready') {
return (
<div className="flex-1 flex flex-col">
{hasConfigurableInputs && (
<TaskInputForm
inputDefs={inputDefs}
values={formValues}
onChange={handleInputChange}
autoFilledKeys={new Set()}
/>
)}
<div className="flex-1 flex items-center justify-center">
<button
onClick={handleRun}
disabled={!pipeline.isConnected || !inputDefs}
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"
>
<Play className="h-4 w-4" />
Run Pipeline
</button>
</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Step progress header */}
{pipeline.currentStep && (
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-duck-dark/3">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-duck-dark">{pipeline.currentStep.taskName}</span>
{pipeline.currentStep.iteration && (
<span className="text-duck-dark/50 text-xs">
({pipeline.currentStep.iteration.current}/{pipeline.currentStep.iteration.total})
<span className="ml-1 font-mono">{pipeline.currentStep.iteration.label}</span>
</span>
)}
{pipeline.currentStep.status === 'running' && (
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
)}
{pipeline.currentStep.status === 'complete' && (
<span className="ml-auto text-xs text-green-600">done</span>
)}
</div>
</div>
)}
{/* Messages */}
<div className="flex-1 min-h-0 overflow-y-auto">
{pipeline.messages.map((msg, i) => (
<div key={i} className="px-4 py-1.5">
<MessageBubble message={msg} onAnswer={() => {}} />
</div>
))}
{pipeline.streamingText && (
<div className="px-4 py-1.5">
<StreamingBubble text={pipeline.streamingText} />
</div>
)}
<div ref={bottomRef} />
</div>
{/* Footer */}
<div className="shrink-0 flex flex-col items-center gap-1 py-3 border-t border-duck-dark/10">
{pipeline.phase === 'running' ? (
<button
onClick={pipeline.stop}
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-red-500/10 text-red-600 text-sm font-medium hover:bg-red-500/20 transition-colors cursor-pointer"
>
<Square className="h-3.5 w-3.5" />
Stop Pipeline
</button>
) : pipeline.hasError ? (
<span className="flex items-center gap-2 text-sm text-red-500 font-medium">
<CircleX className="h-4 w-4" />
Pipeline failed
</span>
) : (
<span className="flex items-center gap-2 text-sm text-green-500 font-medium">
<CircleCheck className="h-4 w-4" />
Pipeline complete
</span>
)}
{pipeline.totalCost && (
<span className="text-xs text-duck-dark/40">
${pipeline.totalCost.totalUSD.toFixed(3)} · {pipeline.totalCost.inputTokens + pipeline.totalCost.outputTokens} tokens
</span>
)}
{pipeline.skippedItems.length > 0 && (
<span className="text-xs text-duck-dark/40">
{pipeline.skippedItems.length} skipped ({pipeline.skippedItems.map((s) => s.label).join(', ')})
</span>
)}
</div>
</div>
);
};
type TaskRunnerModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -439,14 +646,22 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
const taskSettings = settings.tasks;
const entryRef = entryFullPath ?? entryName;
const isScript = task.mode === 'script';
const isPipeline = task.mode === 'pipeline';
// Agentic mode prompt
// Agentic mode prompt (fallback if task has no body)
const defaultInput = promptOverride
?? (entryRef && entryType
? `Execute the task "${task.name}" (${task.dirName}) on the ${entryType}: ${entryRef}`
: `Execute the task "${task.name}" (${task.dirName})`);
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
// Context values for autofill
// Build a ~/relative path for the agent (works inside bwrap sandbox)
const entryRelPath = entryName && cwd.path ? `~/${cwd.path}/${entryName}` : entryName ? `~/${entryName}` : undefined;
const autofillContext: Record<string, string> = {};
if (entryName) autofillContext.entry_name = entryName;
if (entryRelPath) autofillContext.entry_path = entryRelPath;
// Script mode: auto-filled inputs from context (e.g. file_path from file browser)
const autoInputs: Record<string, string> = {};
if (entryFullPath) autoInputs.file_path = entryFullPath;
@@ -477,21 +692,31 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
</div>
{/* Task Runner — branch on mode */}
{isScript ? (
{isPipeline ? (
<PipelineRunner
key="pipeline"
taskDirName={task.dirName}
context={autofillContext}
cwd={entryType === 'directory' && entryName ? (cwd.path ? `${cwd.path}/${entryName}` : entryName) : cwd.path || undefined}
/>
) : isScript ? (
<ScriptRunner
key="script"
taskDirName={task.dirName}
autoInputs={autoInputs}
context={autofillContext}
cwd={cwd.path || undefined}
/>
) : (
<PiMonoInner
key="pi"
taskDirName={task.dirName}
defaultInput={defaultInput}
cwd={cwd}
cwd={entryType === 'directory' && entryName ? { ...cwd, path: cwd.path ? `${cwd.path}/${entryName}` : entryName } : cwd}
initialModel={taskSettings.defaultProvider === 'pi' ? taskSettings.defaultModel : null}
taskInfo={taskInfo}
sandboxed={sandboxed}
context={autofillContext}
/>
)}
</DialogPrimitive.Content>
@@ -0,0 +1,185 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import type { ChatMessage } from '../../../Chat';
type Phase = 'ready' | 'running' | 'done';
type StepDef = {
task: string;
foreach?: string;
};
type StepStatus = {
taskName: string;
iteration?: { current: number; total: number; label: string };
status: 'pending' | 'running' | 'complete' | 'skipped';
cost?: { inputTokens: number; outputTokens: number; totalUSD: number };
};
type ServerMessage =
| { type: 'pipeline:init'; steps: StepDef[] }
| { type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
| { type: 'step:complete'; stepIndex: number; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } }
| { type: 'step:skip'; stepIndex: number; label: string; reason: string }
| { type: 'assistant:delta'; text: string }
| { type: 'assistant:text'; text: string }
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown> }
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean }
| { type: 'pipeline:complete'; totalCost: { inputTokens: number; outputTokens: number; totalUSD: number } }
| { type: 'error'; message: string }
| { type: 'stopped' };
export function usePipelineRunner() {
const [phase, setPhase] = useState<Phase>('ready');
const [isConnected, setIsConnected] = useState(false);
const [steps, setSteps] = useState<StepDef[]>([]);
const [currentStep, setCurrentStep] = useState<StepStatus | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingText, setStreamingText] = useState('');
const [totalCost, setTotalCost] = useState<{ inputTokens: number; outputTokens: number; totalUSD: number } | null>(null);
const [hasError, setHasError] = useState(false);
const [skippedItems, setSkippedItems] = useState<Array<{ label: string; reason: string }>>([]);
const wsRef = useRef<WebSocket | null>(null);
const streamBufferRef = useRef('');
const flushStream = useCallback(() => {
const text = streamBufferRef.current;
if (text) {
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]);
streamBufferRef.current = '';
setStreamingText('');
}
}, []);
useEffect(() => {
const token = localStorage.getItem('BEARER_TOKEN');
if (!token) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${window.location.host}/api/tasks/pipeline/ws?token=${token}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.addEventListener('open', () => setIsConnected(true));
ws.addEventListener('close', () => setIsConnected(false));
ws.addEventListener('message', (ev) => {
try {
const msg = JSON.parse(ev.data) as ServerMessage;
switch (msg.type) {
case 'pipeline:init':
setSteps(msg.steps);
break;
case 'step:start':
// Flush any streaming text from the previous step
flushStream();
// Clear messages for the new step iteration
setMessages([]);
setCurrentStep({
taskName: msg.taskName,
iteration: msg.iteration,
status: 'running',
});
break;
case 'step:complete':
flushStream();
setCurrentStep((prev) => prev ? { ...prev, status: 'complete', cost: msg.cost } : null);
break;
case 'step:skip':
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
break;
case 'assistant:delta':
streamBufferRef.current += msg.text;
setStreamingText(streamBufferRef.current);
break;
case 'assistant:text': {
const text = msg.text || streamBufferRef.current;
if (text) {
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]);
}
streamBufferRef.current = '';
setStreamingText('');
break;
}
case 'tool:start':
flushStream();
setMessages((prev) => [
...prev,
{
role: 'tool' as const,
id: crypto.randomUUID(),
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
output: undefined,
isError: false,
},
]);
break;
case 'tool:result':
setMessages((prev) =>
prev.map((m) =>
m.role === 'tool' && 'toolCallId' in m && m.toolCallId === msg.toolCallId
? { ...m, output: msg.output, isError: msg.isError }
: m,
),
);
break;
case 'pipeline:complete':
flushStream();
setTotalCost(msg.totalCost);
setPhase('done');
break;
case 'error':
flushStream();
setMessages((prev) => [...prev, { role: 'error' as const, id: crypto.randomUUID(), text: msg.message }]);
setHasError(true);
setPhase('done');
break;
case 'stopped':
flushStream();
setPhase('done');
break;
}
} catch {
// ignore
}
});
return () => {
ws.close();
wsRef.current = null;
};
}, []);
const run = useCallback((taskDirName: string, inputs: Record<string, string>, cwd?: string) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
setPhase('running');
setMessages([]);
setStreamingText('');
setTotalCost(null);
setHasError(false);
setSkippedItems([]);
streamBufferRef.current = '';
wsRef.current.send(JSON.stringify({ type: 'run', taskDirName, inputs, cwd }));
}, []);
const stop = useCallback(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
wsRef.current.send(JSON.stringify({ type: 'stop' }));
}, []);
return { phase, isConnected, steps, currentStep, messages, streamingText, totalCost, hasError, skippedItems, run, stop };
}
@@ -10,7 +10,7 @@ export type TaskSummary = {
description: string;
scope: string;
triggers: TriggerConfig[];
mode: 'script' | 'agentic';
mode: 'script' | 'agentic' | 'pipeline';
userId: number | null;
};