mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[eric] workflows: re-apply uncommitted scheduling wip (schedule pill, calendar view, slice)
This commit is contained in:
@@ -12,6 +12,7 @@ the user before calling ScheduleWorkflow).
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import uuid
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
@@ -129,8 +130,11 @@ TOOLS = [
|
||||
"when the user has accepted a proposed change during an Edit "
|
||||
"Agent conversation; the new prompt replaces the existing one "
|
||||
"and persists immediately. The next scheduled run uses the new "
|
||||
"version. Always confirm the change with the user before "
|
||||
"calling this; AskUserQuestion FIRST if there is any ambiguity."
|
||||
"version. Always pass new_label too (a fresh 3-5 word summary) "
|
||||
"so the workflow card visibly reflects the change instead of "
|
||||
"showing the stale old label. Always confirm the change with the "
|
||||
"user before calling this; AskUserQuestion FIRST if there is any "
|
||||
"ambiguity."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
@@ -138,10 +142,47 @@ TOOLS = [
|
||||
"workflow_id": {"type": "string", "description": "The workflow to edit."},
|
||||
"step_idx": {"type": "integer", "description": "0-based index of the step to modify."},
|
||||
"new_text": {"type": "string", "description": "Full replacement prompt text for the step."},
|
||||
"new_label": {"type": "string", "description": "Fresh 3-5 word at-a-glance label for the card (e.g. 'Greet (Victorian)'). Strongly recommended so the change shows."},
|
||||
},
|
||||
"required": ["workflow_id", "step_idx", "new_text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "AddWorkflowStep",
|
||||
"description": (
|
||||
"Add a new step to an existing workflow. Use when the user wants "
|
||||
"the workflow to do something more. The step persists immediately "
|
||||
"and the next run includes it. Confirm with the user via "
|
||||
"AskUserQuestion first if there's any ambiguity about what the "
|
||||
"step should do or where it goes."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workflow_id": {"type": "string", "description": "The workflow to add to."},
|
||||
"text": {"type": "string", "description": "Full prompt text for the new step."},
|
||||
"label": {"type": "string", "description": "Short 3-5 word at-a-glance label for the card."},
|
||||
"position": {"type": "integer", "description": "0-based insert index. Omit to append to the end."},
|
||||
},
|
||||
"required": ["workflow_id", "text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "DeleteWorkflowStep",
|
||||
"description": (
|
||||
"Remove a step from an existing workflow. Persists immediately. "
|
||||
"A workflow must keep at least one step. ALWAYS confirm via "
|
||||
"AskUserQuestion before deleting; the user should pick which step."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workflow_id": {"type": "string", "description": "The workflow to edit."},
|
||||
"step_idx": {"type": "integer", "description": "0-based index of the step to delete."},
|
||||
},
|
||||
"required": ["workflow_id", "step_idx"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "TestWorkflow",
|
||||
"description": (
|
||||
@@ -340,14 +381,65 @@ def handle_edit_step(args: dict) -> dict:
|
||||
steps = cur.get("steps") or []
|
||||
if idx < 0 or idx >= len(steps):
|
||||
return _err(f"step_idx {idx} out of range (workflow has {len(steps)} steps).")
|
||||
# Refresh the at-a-glance label so the card reflects the edit; a preserved
|
||||
# stale label left the step looking unchanged. Agent-supplied label wins,
|
||||
# else clear it so the card falls back to the new text's first words.
|
||||
new_label = (args.get("new_label") or "").strip()
|
||||
new_steps = list(steps)
|
||||
new_steps[idx] = {**new_steps[idx], "text": new_text}
|
||||
new_steps[idx] = {**new_steps[idx], "text": new_text, "label": new_label}
|
||||
r = _call("PATCH", f"/{wid}", {"steps": new_steps})
|
||||
if "_error" in r:
|
||||
return _err(r["_error"])
|
||||
return _ok(f"Step {idx + 1} updated. The next run uses the new prompt.")
|
||||
|
||||
|
||||
def handle_add_step(args: dict) -> dict:
|
||||
wid = args.get("workflow_id") or ""
|
||||
if not wid:
|
||||
return _err("workflow_id is required.")
|
||||
text = (args.get("text") or "").strip()
|
||||
if not text:
|
||||
return _err("text is required.")
|
||||
label = (args.get("label") or "").strip()
|
||||
cur = _call("GET", f"/{wid}")
|
||||
if "_error" in cur:
|
||||
return _err(cur["_error"])
|
||||
steps = list(cur.get("steps") or [])
|
||||
new_step = {"id": "s" + uuid.uuid4().hex[:8], "text": text, "label": label}
|
||||
pos = args.get("position")
|
||||
if isinstance(pos, int) and 0 <= pos <= len(steps):
|
||||
steps.insert(pos, new_step)
|
||||
else:
|
||||
steps.append(new_step)
|
||||
r = _call("PATCH", f"/{wid}", {"steps": steps})
|
||||
if "_error" in r:
|
||||
return _err(r["_error"])
|
||||
return _ok(f"Step added ({len(steps)} total). The next run includes it.")
|
||||
|
||||
|
||||
def handle_delete_step(args: dict) -> dict:
|
||||
wid = args.get("workflow_id") or ""
|
||||
if not wid:
|
||||
return _err("workflow_id is required.")
|
||||
try:
|
||||
idx = int(args.get("step_idx"))
|
||||
except (TypeError, ValueError):
|
||||
return _err("step_idx must be an integer.")
|
||||
cur = _call("GET", f"/{wid}")
|
||||
if "_error" in cur:
|
||||
return _err(cur["_error"])
|
||||
steps = list(cur.get("steps") or [])
|
||||
if idx < 0 or idx >= len(steps):
|
||||
return _err(f"step_idx {idx} out of range (workflow has {len(steps)} steps).")
|
||||
if len(steps) <= 1:
|
||||
return _err("Can't delete the last step; a workflow needs at least one. Edit it instead.")
|
||||
steps.pop(idx)
|
||||
r = _call("PATCH", f"/{wid}", {"steps": steps})
|
||||
if "_error" in r:
|
||||
return _err(r["_error"])
|
||||
return _ok(f"Step {idx + 1} deleted ({len(steps)} remaining).")
|
||||
|
||||
|
||||
def handle_test_workflow(args: dict) -> dict:
|
||||
wid = args.get("workflow_id") or ""
|
||||
if not wid:
|
||||
@@ -368,6 +460,8 @@ HANDLERS = {
|
||||
"ResumeAllWorkflows": handle_resume_all,
|
||||
"RunWorkflowNow": handle_run_now,
|
||||
"EditWorkflowStep": handle_edit_step,
|
||||
"AddWorkflowStep": handle_add_step,
|
||||
"DeleteWorkflowStep": handle_delete_step,
|
||||
"TestWorkflow": handle_test_workflow,
|
||||
}
|
||||
|
||||
|
||||
@@ -393,7 +393,19 @@ async def update_workflow(
|
||||
storage.save_workflow(wf)
|
||||
audit.log_change(wf.id, "user", before, wf.model_dump(mode="json"))
|
||||
scheduler.kick()
|
||||
return _enriched(wf)
|
||||
# Push the change to every open dashboard so an agent-driven edit (the
|
||||
# Edit Agent's add/delete/edit-step tools all PATCH here) refreshes the
|
||||
# card live instead of looking stale until the next full refetch.
|
||||
enriched = _enriched(wf)
|
||||
try:
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
await ws_manager.broadcast_global("workflow:updated", {
|
||||
"workflow_id": wf.id,
|
||||
"workflow": enriched,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return enriched
|
||||
|
||||
|
||||
@workflows.router.delete("/{workflow_id}")
|
||||
@@ -402,6 +414,11 @@ async def delete_workflow(workflow_id: str):
|
||||
if not existed:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
scheduler.kick()
|
||||
try:
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
await ws_manager.broadcast_global("workflow:deleted", {"workflow_id": workflow_id})
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -561,15 +578,24 @@ async def edit_agent_session(workflow_id: str):
|
||||
"1. When the user describes a change, briefly confirm what you'll do.\n"
|
||||
"2. If you need to look at files / search / activate an MCP / etc. to "
|
||||
"verify your idea, use your tools.\n"
|
||||
"3. Call EditWorkflowStep(workflow_id, step_idx, new_text) to apply a "
|
||||
"prompt change to a specific step. The change persists immediately. "
|
||||
"Confirm with the user via AskUserQuestion FIRST if there's any "
|
||||
"ambiguity about what they want.\n"
|
||||
"3. To change the workflow's steps, call the matching tool; each "
|
||||
"persists immediately and refreshes the user's card live:\n"
|
||||
" - EditWorkflowStep(workflow_id, step_idx, new_text, new_label) to "
|
||||
"rewrite a step. ALWAYS pass new_label (a fresh 3-5 word summary) so "
|
||||
"the card reflects the change instead of the stale old label.\n"
|
||||
" - AddWorkflowStep(workflow_id, text, label) to add a step.\n"
|
||||
" - DeleteWorkflowStep(workflow_id, step_idx) to remove one.\n"
|
||||
" Confirm via AskUserQuestion FIRST if there's any ambiguity.\n"
|
||||
"4. Call TestWorkflow(workflow_id) to spawn a sibling Test Agent that "
|
||||
"runs the latest version end-to-end. Use this after a change to verify "
|
||||
"it works.\n\n"
|
||||
"Be brief in your replies. Don't restate the whole workflow back; the "
|
||||
"user can see it. Just confirm what changed and what you're doing."
|
||||
"user can see it. Just confirm what changed and what you're doing.\n"
|
||||
"Write like a normal chat: plain conversational sentences. When you "
|
||||
"suggest changes, describe them in prose (e.g. \"I could add a step "
|
||||
"that...\"). Never dump raw JSON, arrays, or code blocks of step "
|
||||
"objects at the user; that belongs in your EditWorkflowStep tool call, "
|
||||
"not the message."
|
||||
)
|
||||
config = AgentConfig(
|
||||
name=f"Edit Agent: {wf.title}",
|
||||
|
||||
@@ -7,7 +7,7 @@ import Tooltip, { tooltipClasses } from '@mui/material/Tooltip';
|
||||
import Icon from '@mui/material/Icon';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import AddRounded from '@mui/icons-material/AddRounded';
|
||||
import HistoryRounded from '@mui/icons-material/HistoryRounded';
|
||||
import CalendarMonthRounded from '@mui/icons-material/CalendarMonthRounded';
|
||||
|
||||
// Custom near-circular speech bubble with a teardrop tail at the
|
||||
// bottom-left. The bubble body is a rounded square with corner radius
|
||||
@@ -253,20 +253,19 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
setViewSearch('');
|
||||
}, [viewPickerOpen, dispatch]);
|
||||
|
||||
// Opens the schedule CALENDAR (scheduled workflows on a calendar). Chat
|
||||
// search/resume lives in the global search palette (the OpenSwarm center
|
||||
// at the top), not here, so this no longer dispatches a history search.
|
||||
const handleOpenHistory = useCallback(() => {
|
||||
if (historyOpen) {
|
||||
setHistoryOpen(false);
|
||||
setHistoryQuery('');
|
||||
dispatch(clearHistorySearch());
|
||||
return;
|
||||
}
|
||||
setViewPickerOpen(false);
|
||||
setViewSearch('');
|
||||
setPopoverMode('schedule');
|
||||
setHistoryOpen(true);
|
||||
setHistoryQuery('');
|
||||
dispatch(clearHistorySearch());
|
||||
dispatch(searchHistory({ q: '', limit: HISTORY_PAGE_SIZE, offset: 0, dashboardId }));
|
||||
}, [historyOpen, dispatch, dashboardId]);
|
||||
}, [historyOpen]);
|
||||
|
||||
const handleHistorySelect = useCallback((sessionId: string) => {
|
||||
onHistoryResume(sessionId);
|
||||
@@ -409,6 +408,65 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
|
||||
return (
|
||||
<>
|
||||
{(inputOpen || historyOpen) && (
|
||||
// Image #54: paired mode pills above the composer/popover.
|
||||
// The two states are mutually exclusive: opening one closes the
|
||||
// other so the body underneath only renders one thing at a time.
|
||||
<Box sx={{ display: 'flex', gap: 0.5, mb: 0.75, pl: 0.25 }}>
|
||||
<Box
|
||||
onClick={() => {
|
||||
if (historyOpen) {
|
||||
handleCloseHistory();
|
||||
onNewAgent();
|
||||
}
|
||||
// If already in inputOpen, this is a no-op (we're already
|
||||
// in new chat). The visible active styling tells the user
|
||||
// that. Clicking again does nothing intentionally.
|
||||
}}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.3,
|
||||
fontSize: '0.74rem', fontWeight: 600,
|
||||
color: c.text.primary,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${inputOpen && !historyOpen ? c.border.medium : c.border.subtle}`,
|
||||
boxShadow: inputOpen && !historyOpen ? c.shadow.sm : 'none',
|
||||
px: 0.85, py: 0.3, borderRadius: 999,
|
||||
cursor: historyOpen ? 'pointer' : 'default',
|
||||
'&:hover': historyOpen ? { bgcolor: c.bg.elevated } : {},
|
||||
}}>
|
||||
<AddRounded sx={{ fontSize: 12 }} />
|
||||
New Chat
|
||||
</Box>
|
||||
<Box
|
||||
onClick={() => {
|
||||
// Schedule is a destination, not a toggle: clicking it always
|
||||
// lands on (and stays on) the calendar. It used to call
|
||||
// handleCloseHistory when already open, which read as "Schedule
|
||||
// does nothing" because it closed the calendar you were viewing.
|
||||
// Close the composer first; inputOpen takes precedence in the
|
||||
// render branch below so the popover would hide behind it.
|
||||
if (inputOpen) onCancel();
|
||||
setPopoverMode('schedule');
|
||||
if (!historyOpen) setHistoryOpen(true);
|
||||
}}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.3,
|
||||
fontSize: '0.74rem', fontWeight: 600,
|
||||
color: historyOpen ? c.text.primary : c.text.secondary,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${historyOpen ? c.border.medium : c.border.subtle}`,
|
||||
boxShadow: historyOpen ? c.shadow.sm : 'none',
|
||||
px: 0.85, py: 0.3, borderRadius: 999,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: c.bg.elevated },
|
||||
}}>
|
||||
<CalendarMonthRounded sx={{ fontSize: 12 }} />
|
||||
Schedule
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<MotionBox
|
||||
ref={containerRef}
|
||||
layout
|
||||
@@ -428,7 +486,11 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
width: viewPickerOpen ? 580 : historyOpen ? undefined : isExpanded ? 540 : undefined,
|
||||
}}
|
||||
>
|
||||
{inputOpen ? (
|
||||
{inputOpen && !historyOpen ? (
|
||||
// historyOpen wins over the composer: clicking Schedule closes the
|
||||
// composer via onCancel(), but that's a parent-state update that
|
||||
// lands a render late, so without this guard the composer kept
|
||||
// covering the calendar (the "Schedule does nothing" bug).
|
||||
// data-onboarding-scope="dock" makes AC's per-agent resolver prefer this dock chat input over existing agent cards.
|
||||
<div
|
||||
data-onboarding-scope="dock"
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
// Image #38, #48: Edit Agent embedded in the workflow card.
|
||||
// Creates a real, sticky-per-workflow agent session via /workflows/{id}/
|
||||
// edit-agent-session and embeds AgentChat so tool calls render as their
|
||||
// normal cards (MCP Activation, Gmail Query, etc.). Header keeps the
|
||||
// subtitle on the left and Settings + Discard + Save on the right. In
|
||||
// fix mode (Image #48) the very first message in the session is a
|
||||
// failure-context prompt, and a red prefix card renders above the chat
|
||||
// so the user sees Why we're here at a glance.
|
||||
// normal cards (MCP Activation, Gmail Query, etc.). The card IS the chat:
|
||||
// a collapsible "Workflow" strip on top peeks at the live steps, the chat
|
||||
// fills the rest. In fix mode (Image #48) the first message is a
|
||||
// failure-context prompt and a red prefix card renders above the chat.
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import DeleteOutlineRounded from '@mui/icons-material/DeleteOutlineRounded';
|
||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||
import BuildRounded from '@mui/icons-material/BuildRounded';
|
||||
import TuneRounded from '@mui/icons-material/TuneRounded';
|
||||
import KeyboardArrowDownRounded from '@mui/icons-material/KeyboardArrowDownRounded';
|
||||
import ScienceOutlined from '@mui/icons-material/ScienceOutlined';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { clearFixSeed, setCardSidecar, updateWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { DEFAULT_CARD_W, DEFAULT_CARD_H, placeCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { clearFixSeed, updateWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { fetchSession } from '@/shared/state/agentsSlice';
|
||||
import StepList from './StepList';
|
||||
import { API_BASE, getAuthToken } from '@/shared/config';
|
||||
import StepList from './StepList';
|
||||
import AgentChat from '@/app/pages/AgentChat/AgentChat';
|
||||
|
||||
interface Props {
|
||||
@@ -34,47 +25,12 @@ interface Props {
|
||||
isFixMode?: boolean;
|
||||
}
|
||||
|
||||
function InlineSubtitle({ workflow }: { workflow: Workflow }) {
|
||||
const c = useClaudeTokens();
|
||||
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
|
||||
const runs = useAppSelector((s) => s.workflows.runs[workflow.id]);
|
||||
const modelLabel = React.useMemo(() => {
|
||||
if (!workflow?.model) return '';
|
||||
for (const list of Object.values(modelsByProvider || {})) {
|
||||
for (const m of (list as Array<{ value: string; label?: string }>) || []) {
|
||||
if (m.value === workflow.model) return m.label || workflow.model;
|
||||
}
|
||||
}
|
||||
return workflow.model;
|
||||
}, [workflow?.model, modelsByProvider]);
|
||||
const duration = React.useMemo(() => {
|
||||
if (!runs || runs.length === 0) return '';
|
||||
const last = runs.find((r) => r.finished_at);
|
||||
if (!last || !last.finished_at) return '';
|
||||
const ms = new Date(last.finished_at).getTime() - new Date(last.started_at).getTime();
|
||||
if (ms <= 0) return '';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
||||
return `${Math.floor(ms / 60_000)}m`;
|
||||
}, [runs]);
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1.25, fontSize: '0.82rem', color: c.text.muted, minWidth: 0, overflow: 'hidden' }}>
|
||||
{modelLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modelLabel}</Box>}
|
||||
{workflow.mode && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{workflow.mode}</Box>}
|
||||
{duration && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{duration}</Box>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EditAgentView({ workflow, steps, isFixMode = false }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
|
||||
const wfCardPos = useAppSelector((s) => s.dashboardLayout.workflowCards[workflow.id]);
|
||||
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
|
||||
const fixSeed = card?.fixSeed || null;
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [showSaveBeforeTest, setShowSaveBeforeTest] = useState(false);
|
||||
const [stepsOpen, setStepsOpen] = useState(true);
|
||||
const [fixPrefixExpanded, setFixPrefixExpanded] = useState(false);
|
||||
const [editSessionId, setEditSessionId] = useState<string | null>(workflow.edit_agent_session_id || null);
|
||||
const [seedSent, setSeedSent] = useState(false);
|
||||
@@ -131,110 +87,47 @@ export default function EditAgentView({ workflow, steps, isFixMode = false }: Pr
|
||||
})();
|
||||
}, [editSessionId, editSession, seedSent, isFixMode, fixSeed]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
const onDone = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
|
||||
const onTest = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/test-run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
|
||||
body: JSON.stringify({ steps: steps.map((s) => ({ id: s.id, text: s.text, label: s.label || null })) }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const sessionId = data?.session_id as string | undefined;
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const { store } = await import('@/shared/state/store');
|
||||
if (!store.getState().agents.sessions[sessionId]) {
|
||||
try { await dispatch(fetchSession(sessionId)).unwrap(); } catch { /* not fatal */ }
|
||||
}
|
||||
if (!store.getState().dashboardLayout.cards[sessionId] && wfCardPos) {
|
||||
dispatch(placeCard({
|
||||
sessionId,
|
||||
x: wfCardPos.x + wfCardPos.width + 60,
|
||||
y: wfCardPos.y,
|
||||
width: DEFAULT_CARD_W,
|
||||
height: DEFAULT_CARD_H,
|
||||
expandedSessionIds,
|
||||
}));
|
||||
}
|
||||
dispatch(setPendingFocusAgentId(sessionId));
|
||||
} catch { /* best-effort */ }
|
||||
dispatch(setCardSidecar({ workflowId: workflow.id, sessionId, kind: 'testing' }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, workflow.id, steps, dispatch, wfCardPos, expandedSessionIds]);
|
||||
|
||||
const onTestClick = useCallback(() => {
|
||||
// No local draft to warn about anymore (the Edit Agent's tool will
|
||||
// mutate workflow.steps directly when wired). Skip the modal for now.
|
||||
void onTest();
|
||||
}, [onTest]);
|
||||
void showSaveBeforeTest; void setShowSaveBeforeTest;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<InlineSubtitle workflow={workflow} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Tooltip title="Permissions, actions, cost cap">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
{/* The "tab with the workflow inside": a collapsible strip that peeks
|
||||
at the live steps (they update as the agent edits) without leaving
|
||||
the chat. Done drops back to the compact workflow card. */}
|
||||
<Box sx={{ flexShrink: 0, mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
<Box
|
||||
onClick={() => dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit', editFacet: 'Actions' } }))}
|
||||
onClick={() => setStepsOpen((x) => !x)}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 28, height: 28, borderRadius: 999,
|
||||
color: c.text.secondary, cursor: 'pointer',
|
||||
'&:hover': { color: c.text.primary, bgcolor: c.bg.elevated },
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.25, cursor: 'pointer',
|
||||
fontSize: '0.82rem', fontWeight: 600, color: c.text.secondary,
|
||||
'&:hover': { color: c.text.primary },
|
||||
}}>
|
||||
<TuneRounded sx={{ fontSize: 16 }} />
|
||||
<KeyboardArrowDownRounded sx={{ fontSize: 16, transform: stepsOpen ? 'none' : 'rotate(-90deg)', transition: 'transform 0.15s ease' }} />
|
||||
Workflow ({steps.length} step{steps.length === 1 ? '' : 's'})
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Tooltip title="Spawn a Test Agent that runs the latest workflow next to this card with a Testing arrow chip.">
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box
|
||||
onClick={onTestClick}
|
||||
onClick={onDone}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.3,
|
||||
fontSize: '0.78rem', fontWeight: 700,
|
||||
color: c.accent.primary, bgcolor: 'transparent',
|
||||
px: 1, py: 0.4, borderRadius: 999,
|
||||
border: `1px solid ${c.accent.primary}55`,
|
||||
cursor: busy ? 'not-allowed' : 'pointer',
|
||||
opacity: busy ? 0.5 : 1,
|
||||
'&:hover': { bgcolor: c.accent.primary + '14' },
|
||||
}}>
|
||||
<ScienceOutlined sx={{ fontSize: 14 }} />
|
||||
Test
|
||||
sx={{ fontSize: '0.8rem', fontWeight: 600, color: c.text.muted, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>
|
||||
Done
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<HeaderBtn
|
||||
label="Discard"
|
||||
icon={<DeleteOutlineRounded sx={{ fontSize: 16 }} />}
|
||||
onClick={onClose}
|
||||
tone="muted"
|
||||
/>
|
||||
<HeaderBtn
|
||||
label="Save"
|
||||
icon={<SaveOutlinedIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={onClose}
|
||||
tone="filled"
|
||||
/>
|
||||
</Box>
|
||||
{stepsOpen && (
|
||||
<Box sx={{ mt: 0.75 }}>
|
||||
{isFixMode && fixSeed && <FixPrefixCard seed={fixSeed} expanded={fixPrefixExpanded} onToggle={() => setFixPrefixExpanded((x) => !x)} />}
|
||||
<StepList steps={steps} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<StepList steps={steps} />
|
||||
{isFixMode && fixSeed && <FixPrefixCard seed={fixSeed} expanded={fixPrefixExpanded} onToggle={() => setFixPrefixExpanded((x) => !x)} />}
|
||||
{/* Embedded real Edit Agent chat. AgentChat owns the composer +
|
||||
message list + tool-call card rendering, matching Image #48
|
||||
(MCP Activation, Gmail Query, etc.). embedded=true tells it to
|
||||
skip its own dashboard chrome since we own the surrounding card. */}
|
||||
<Box sx={{ flex: 1, minHeight: 280, display: 'flex', flexDirection: 'column', mx: -1, mb: -1 }}>
|
||||
{/* The card IS the chat. AgentChat owns the composer + message list +
|
||||
tool-call cards. Negative margins cancel the card body's p:2 so the
|
||||
thread runs edge-to-edge like a normal chat (it supplies its own px). */}
|
||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', mx: -2, mb: -2 }}>
|
||||
{editSessionId ? (
|
||||
<AgentChat sessionId={editSessionId} embedded autoFocus />
|
||||
) : (
|
||||
@@ -243,10 +136,6 @@ export default function EditAgentView({ workflow, steps, isFixMode = false }: Pr
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Dialog open={false} onClose={() => {}} maxWidth="sm" fullWidth>
|
||||
<Box />
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -299,25 +188,3 @@ function FixPrefixCard({ seed, expanded, onToggle }: { seed: { stepIdx: number;
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderBtn({ label, icon, onClick, tone, disabled }: { label: string; icon: React.ReactNode; onClick: () => void; tone: 'muted' | 'filled'; disabled?: boolean }) {
|
||||
const c = useClaudeTokens();
|
||||
const filled = tone === 'filled';
|
||||
return (
|
||||
<Box
|
||||
onClick={disabled ? undefined : onClick}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
fontSize: '0.82rem', fontWeight: 700,
|
||||
px: 1.1, py: 0.45, borderRadius: 999,
|
||||
color: filled ? '#fff' : c.text.secondary,
|
||||
bgcolor: filled ? c.text.primary : 'transparent',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
'&:hover': filled ? { filter: 'brightness(1.05)' } : { color: c.text.primary, bgcolor: c.bg.elevated },
|
||||
}}>
|
||||
{icon}
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,9 @@ export default function SchedulePopover({
|
||||
hideTopChrome = false,
|
||||
}: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const [calendarView, setCalendarView] = useState<'Week' | 'Month' | 'List'>('Week');
|
||||
// List leads: it's the at-a-glance "what's coming up" the user wants first,
|
||||
// with Week/Month as the calendar grids behind it.
|
||||
const [calendarView, setCalendarView] = useState<'Week' | 'Month' | 'List'>('List');
|
||||
const [refDate, setRefDate] = useState<Date>(() => new Date());
|
||||
const workflows = useAppSelector((s) => s.workflows.items);
|
||||
|
||||
@@ -167,7 +169,7 @@ export default function SchedulePopover({
|
||||
{mode === 'schedule' && (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, pt: 1, pb: 0.5, flexShrink: 0 }}>
|
||||
{(['Week', 'Month', 'List'] as const).map((v) => (
|
||||
{(['List', 'Week', 'Month'] as const).map((v) => (
|
||||
<Box key={v} onClick={() => setCalendarView(v)} role="button" sx={{ fontSize: '0.85rem', fontWeight: calendarView === v ? 700 : 500, px: 0.75, pt: 0.4, pb: 0.55, color: calendarView === v ? c.text.primary : c.text.muted, borderBottom: `2px solid ${calendarView === v ? c.accent.primary : 'transparent'}`, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>{v}</Box>
|
||||
))}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DeleteOutlineRounded from '@mui/icons-material/DeleteOutlineRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
@@ -133,7 +132,7 @@ export default function SchedulingView({ workflow, steps }: Props) {
|
||||
}, [pending, dispatch, workflow.id, workflow.updated_at]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, flex: '1 0 auto' }}>
|
||||
{/* Inline header replacement. Image #49: subtitle on LEFT, Cancel
|
||||
on RIGHT. Cancel matches the subtitle's weight/size/color so the
|
||||
row reads as peers, not a heavy CTA; it just reddens on hover. */}
|
||||
@@ -153,16 +152,56 @@ export default function SchedulingView({ workflow, steps }: Props) {
|
||||
Cancel task scheduling
|
||||
</Box>
|
||||
</Box>
|
||||
<StepList steps={steps} />
|
||||
<Typography sx={{ fontSize: '0.92rem', color: c.text.secondary, lineHeight: 1.45, mt: 0.5 }}>
|
||||
When should this workflow run (e.g. every Wednesday at 1pm)
|
||||
</Typography>
|
||||
{/* Steps in a soft bubble so they read as a quoted "here's the workflow"
|
||||
block inside the chat, not loose body text. */}
|
||||
<Box sx={{ p: 1.5, borderRadius: `${c.radius.lg}px`, bgcolor: c.bg.elevated, border: `1px solid ${c.border.subtle}` }}>
|
||||
<StepList steps={steps} />
|
||||
</Box>
|
||||
{/* Prompt in its own bubble, like an agent message asking for the cadence. */}
|
||||
<Box sx={{ p: 1.5, borderRadius: `${c.radius.lg}px`, bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}` }}>
|
||||
<Typography sx={{ fontSize: '0.92rem', color: c.text.secondary, lineHeight: 1.45 }}>
|
||||
When should this workflow run (e.g. every Wednesday at 1pm)
|
||||
</Typography>
|
||||
</Box>
|
||||
{error && (
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.status.error }}>{error}</Typography>
|
||||
)}
|
||||
{/* Spacer pushes the composer to the bottom of the card so the view
|
||||
reads like a normal chat (prompt up top, input docked below). */}
|
||||
<Box sx={{ flex: 1, minHeight: 40 }} />
|
||||
{/* Spacer docks the composer (and the inline confirm) to the bottom of
|
||||
the card so it reads like a chat: prompt up top, input below. */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{/* Inline confirm (the HITL "always ask before scheduling" gate). Lives
|
||||
in the card right above the composer instead of a screen-dimming
|
||||
modal, so it feels native to the chat. */}
|
||||
{pending && (
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap',
|
||||
p: 1.25, mb: 1, borderRadius: `${c.radius.lg}px`,
|
||||
bgcolor: c.bg.elevated, border: `1px solid ${c.accent.primary}55`,
|
||||
}}>
|
||||
<Typography sx={{ flex: 1, minWidth: 180, fontSize: '0.86rem', color: c.text.secondary, lineHeight: 1.45 }}>
|
||||
Set <b>{workflow.title}</b> to run <b>{describe(pending)}</b>?
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
onClick={() => setPending(null)}
|
||||
role="button"
|
||||
sx={{ fontSize: '0.84rem', color: c.text.secondary, px: 1, py: 0.5, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>
|
||||
Cancel
|
||||
</Box>
|
||||
<Box
|
||||
onClick={onConfirm}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.84rem', fontWeight: 700,
|
||||
color: '#fff', bgcolor: c.accent.primary,
|
||||
px: 1.4, py: 0.5, borderRadius: 999, cursor: 'pointer',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
{busy ? 'Applying…' : 'Schedule it'}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{/* Real ChatInput (same one the toolbar / agent chat use) so the
|
||||
composer matches Image #54 / #64 exactly: live model picker,
|
||||
mode picker, thinking level, paperclip + mic, the works. We
|
||||
@@ -182,36 +221,6 @@ export default function SchedulingView({ workflow, steps }: Props) {
|
||||
disabled={busy}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Dialog open={!!pending} onClose={() => setPending(null)}>
|
||||
<Box sx={{ p: 2.5, minWidth: 360, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography sx={{ fontSize: '1rem', fontWeight: 700, color: c.text.primary }}>
|
||||
Schedule this workflow?
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.9rem', color: c.text.secondary, lineHeight: 1.5 }}>
|
||||
The agent wants to set <b>{workflow.title}</b> to run <b>{pending && describe(pending)}</b>. You can change or cancel this anytime.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 0.5 }}>
|
||||
<Box
|
||||
onClick={() => setPending(null)}
|
||||
role="button"
|
||||
sx={{ fontSize: '0.86rem', color: c.text.secondary, px: 1, py: 0.6, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>
|
||||
Cancel
|
||||
</Box>
|
||||
<Box
|
||||
onClick={onConfirm}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.86rem', fontWeight: 700,
|
||||
color: '#fff', bgcolor: c.accent.primary,
|
||||
px: 1.4, py: 0.55, borderRadius: 999, cursor: 'pointer',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
{busy ? 'Applying…' : 'Schedule it'}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ const EDGE_THICKNESS = 6;
|
||||
const CORNER_SIZE = 14;
|
||||
const MIN_W = 360;
|
||||
const MIN_H = 280;
|
||||
// Chat-style views (edit/fix/scheduling) hold an expanded-agent-chat height so
|
||||
// they read like a real chat instead of shrink-wrapping to their short content.
|
||||
const CHAT_VIEW_H = 620;
|
||||
|
||||
const CURSOR_MAP: Record<ResizeDir, string> = {
|
||||
n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize',
|
||||
@@ -355,16 +358,11 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
|
||||
const displayY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy);
|
||||
const displayW = localResize?.w ?? cardWidth;
|
||||
const displayH = localResize?.h ?? cardHeight;
|
||||
// Chat views embed a full AgentChat that needs a fixed scroll viewport;
|
||||
// every other view should size to its content so nothing is cut off and
|
||||
// there's no dead space below short content. While the user is actively
|
||||
// resizing, honor the dragged height.
|
||||
// Chat views host a composer + conversation, so they keep a bounded height
|
||||
// (composer docked at the bottom, content scrolls) like a normal chat card.
|
||||
// Everything else fits to its content. Scheduling is a chat too (you talk to
|
||||
// it in natural language), so it belongs here, not in the fit-to-content set.
|
||||
// edit/fix/scheduling embed a chat (composer docked, body scrolls), so they
|
||||
// hold a bounded chat-sized height instead of shrink-wrapping to their short
|
||||
// content. Everything else fits to content. A drag can still grow them.
|
||||
const isChatView = card?.view === 'edit_agent' || card?.view === 'fix_agent' || card?.view === 'scheduling';
|
||||
const displayH = localResize?.h ?? (isChatView ? Math.max(CHAT_VIEW_H, cardHeight) : cardHeight);
|
||||
const autoHeight = !isChatView && !localResize && !isResizing;
|
||||
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
dispatch(setPausedAll(!paused));
|
||||
}, [dispatch, paused]);
|
||||
|
||||
const [view, setView] = useState<CalendarView>('Week');
|
||||
const [view, setView] = useState<CalendarView>('List');
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
const [refDate, setRefDate] = useState(new Date());
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -414,7 +414,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
{viewOpen && (
|
||||
<Box sx={{ position: 'absolute', top: '100%', right: 0, mt: 0.5, bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, boxShadow: c.shadow.md, zIndex: 5, minWidth: 110 }}>
|
||||
{(['Week', 'Month', 'List'] as const).map((v) => (
|
||||
{(['List', 'Week', 'Month'] as const).map((v) => (
|
||||
<Box
|
||||
key={v}
|
||||
data-no-drag
|
||||
|
||||
@@ -334,6 +334,17 @@ const slice = createSlice({
|
||||
const card = state.openCards[action.payload];
|
||||
if (card) card.fixSeed = null;
|
||||
},
|
||||
// Live workflow changes pushed over WS (e.g. the Edit Agent's
|
||||
// add/delete/edit-step tools). Keeps an open card in sync without a
|
||||
// full refetch; idempotent, so a window receiving the echo of its own
|
||||
// edit just re-sets the same data.
|
||||
upsertWorkflow(state, action: { payload: Workflow }) {
|
||||
state.items[action.payload.id] = action.payload;
|
||||
},
|
||||
removeWorkflow(state, action: { payload: string }) {
|
||||
delete state.items[action.payload];
|
||||
delete state.runs[action.payload];
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
@@ -370,5 +381,7 @@ export const {
|
||||
toggleExpandedStep,
|
||||
setCardSidecar,
|
||||
clearFixSeed,
|
||||
upsertWorkflow,
|
||||
removeWorkflow,
|
||||
} = slice.actions;
|
||||
export default slice.reducer;
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { streamStart, streamDelta, streamEnd } from '../state/streamingSlice';
|
||||
import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP, addWorkflowCard } from '../state/dashboardLayoutSlice';
|
||||
import { upsertOutput } from '../state/outputsSlice';
|
||||
import { upsertRun, ackRun, runWorkflowNow, openWorkflowCard } from '../state/workflowsSlice';
|
||||
import { upsertRun, ackRun, runWorkflowNow, openWorkflowCard, upsertWorkflow, removeWorkflow } from '../state/workflowsSlice';
|
||||
import { getAuthToken } from '../config';
|
||||
import { notifyAgentCompletion } from '../notifications';
|
||||
|
||||
@@ -710,6 +710,18 @@ class WebSocketManager {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'workflow:updated':
|
||||
if (data.workflow) {
|
||||
store.dispatch(upsertWorkflow(data.workflow));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'workflow:deleted':
|
||||
if (data.workflow_id) {
|
||||
store.dispatch(removeWorkflow(data.workflow_id));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'workflow:notify':
|
||||
try {
|
||||
notifyAgentCompletion({
|
||||
|
||||
Reference in New Issue
Block a user