[eric] workflows: re-apply uncommitted scheduling wip (schedule pill, calendar view, slice)

This commit is contained in:
ciregenz
2026-05-27 11:56:18 -07:00
parent f0c2715713
commit 652ef2b055
10 changed files with 321 additions and 238 deletions
+97 -3
View File
@@ -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,
}
+32 -6
View File
@@ -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;
+13 -1
View File
@@ -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({