From 652ef2b0554390ad3721eb9ef5db2e6f36268c44 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 27 May 2026 11:56:18 -0700 Subject: [PATCH] [eric] workflows: re-apply uncommitted scheduling wip (schedule pill, calendar view, slice) --- backend/apps/agents/schedule_mcp_server.py | 100 ++++++++- backend/apps/workflows/workflows.py | 38 +++- .../app/pages/Dashboard/DashboardToolbar.tsx | 78 ++++++- .../src/app/pages/Workflows/EditAgentView.tsx | 203 +++--------------- .../app/pages/Workflows/SchedulePopover.tsx | 6 +- .../app/pages/Workflows/SchedulingView.tsx | 87 ++++---- .../src/app/pages/Workflows/WorkflowCard.tsx | 16 +- .../app/pages/Workflows/WorkflowsHubCard.tsx | 4 +- frontend/src/shared/state/workflowsSlice.ts | 13 ++ frontend/src/shared/ws/WebSocketManager.ts | 14 +- 10 files changed, 321 insertions(+), 238 deletions(-) diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py index 5a75a603..8997b23d 100644 --- a/backend/apps/agents/schedule_mcp_server.py +++ b/backend/apps/agents/schedule_mcp_server.py @@ -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, } diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index cb1b4634..9e0d6649 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -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}", diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 666873b7..3c5e9fde 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -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( 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( 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. + + { + 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 } : {}, + }}> + + New Chat + + { + // 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 }, + }}> + + Schedule + + + )} ( 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.
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 ( - - {modelLabel && {modelLabel}} - {workflow.mode && {workflow.mode}} - {duration && {duration}} - - ); -} - 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(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 ( - - - - - + + {/* 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. */} + + 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 }, }}> - + + Workflow ({steps.length} step{steps.length === 1 ? '' : 's'}) - - + - - Test + sx={{ fontSize: '0.8rem', fontWeight: 600, color: c.text.muted, cursor: 'pointer', '&:hover': { color: c.text.primary } }}> + Done - - } - onClick={onClose} - tone="muted" - /> - } - onClick={onClose} - tone="filled" - /> + + {stepsOpen && ( + + {isFixMode && fixSeed && setFixPrefixExpanded((x) => !x)} />} + + + )} - - {isFixMode && fixSeed && 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. */} - + {/* 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). */} + {editSessionId ? ( ) : ( @@ -243,10 +136,6 @@ export default function EditAgentView({ workflow, steps, isFixMode = false }: Pr )} - - {}} maxWidth="sm" fullWidth> - - ); } @@ -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 ( - - {icon} - {label} - - ); -} diff --git a/frontend/src/app/pages/Workflows/SchedulePopover.tsx b/frontend/src/app/pages/Workflows/SchedulePopover.tsx index 8f9bad29..350b7f3a 100644 --- a/frontend/src/app/pages/Workflows/SchedulePopover.tsx +++ b/frontend/src/app/pages/Workflows/SchedulePopover.tsx @@ -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(() => new Date()); const workflows = useAppSelector((s) => s.workflows.items); @@ -167,7 +169,7 @@ export default function SchedulePopover({ {mode === 'schedule' && ( - {(['Week', 'Month', 'List'] as const).map((v) => ( + {(['List', 'Week', 'Month'] as const).map((v) => ( 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} ))} diff --git a/frontend/src/app/pages/Workflows/SchedulingView.tsx b/frontend/src/app/pages/Workflows/SchedulingView.tsx index 2e421f74..92ea27c7 100644 --- a/frontend/src/app/pages/Workflows/SchedulingView.tsx +++ b/frontend/src/app/pages/Workflows/SchedulingView.tsx @@ -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 ( - + {/* 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 - - - When should this workflow run (e.g. every Wednesday at 1pm) - + {/* Steps in a soft bubble so they read as a quoted "here's the workflow" + block inside the chat, not loose body text. */} + + + + {/* Prompt in its own bubble, like an agent message asking for the cadence. */} + + + When should this workflow run (e.g. every Wednesday at 1pm) + + {error && ( {error} )} - {/* Spacer pushes the composer to the bottom of the card so the view - reads like a normal chat (prompt up top, input docked below). */} - + {/* 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. */} + + {/* 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 && ( + + + Set {workflow.title} to run {describe(pending)}? + + + 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 + + + {busy ? 'Applying…' : 'Schedule it'} + + + + )} {/* 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} /> - - setPending(null)}> - - - Schedule this workflow? - - - The agent wants to set {workflow.title} to run {pending && describe(pending)}. You can change or cancel this anytime. - - - 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 - - - {busy ? 'Applying…' : 'Schedule it'} - - - - ); } diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index 83412190..7e40cdde 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -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 = { n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize', @@ -355,16 +358,11 @@ const WorkflowCard: React.FC = ({ 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); diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx index bb57df4a..bfada286 100644 --- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -128,7 +128,7 @@ const WorkflowsHubCard: React.FC = ({ dispatch(setPausedAll(!paused)); }, [dispatch, paused]); - const [view, setView] = useState('Week'); + const [view, setView] = useState('List'); const [viewOpen, setViewOpen] = useState(false); const [refDate, setRefDate] = useState(new Date()); const [search, setSearch] = useState(''); @@ -414,7 +414,7 @@ const WorkflowsHubCard: React.FC = ({ {viewOpen && ( - {(['Week', 'Month', 'List'] as const).map((v) => ( + {(['List', 'Week', 'Month'] as const).map((v) => ( { builder @@ -370,5 +381,7 @@ export const { toggleExpandedStep, setCardSidecar, clearFixSeed, + upsertWorkflow, + removeWorkflow, } = slice.actions; export default slice.reducer; diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 35a8edc5..d1d095d4 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -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({