From 2bb5657ec0779229e233dd3a021c24423b409cf2 Mon Sep 17 00:00:00 2001 From: Aidan Date: Tue, 16 Jun 2026 23:53:04 -0700 Subject: [PATCH] [aidan] feat/scheduled-tasks: calendar, rename, and edit workflows (#94) * [aidan] bug: fix schedule button * [aidan] fix/agent-errors: surface provider rate limits * [aidan] ux/cards: click-to-rename for chat and workflow titles Single-click a card's title to enter edit mode inline. Commit on Enter/blur, cancel on Escape. Rename persists via PATCH for workflows and sessions. * [aidan] feat/workflows: seed build prompt for zero-step workflows When a new workflow has no steps, seed the agent with a prompt asking the user to describe what the workflow should do, rather than starting blank. * [aidan] feat/workflows: add-to-schedule popover for unscheduled workflows Clicking the "+" on an unscheduled workflow row opens a popover with two options: - Keep this schedule: enables the workflow's existing cadence and moves it to Scheduled - Change schedule: opens the scheduling editor to pick a different time * [aidan] ux/workflows: wire add-to-schedule popover and simplify New button - Made the "+" icon on unscheduled workflow rows clickable, opening a popover to keep or change the schedule - Removed AddIcon from toolbar "New" button (now reads "New" instead of "+ New") * [aidan] fix/scheduled-tasks: open schedule calendar when Schedule pill clicked Fixed the Schedule pill click being swallowed by the toolbar's dismiss handler. Exempted the toolbar pills via data-toolbar-pills so their click handlers fire. * [aidan] ux/workflows: open New workflow in agent build chat instead of empty card When creating a new workflow from the hub, open it in edit_agent view (with the agent builder chat) instead of a preview card. The workflow is created on the backend first so the embedded session has a real ID. --- backend/apps/agents/agent_manager.py | 38 ++++- backend/apps/workflows/workflows.py | 14 +- backend/tests/test_free_trial.py | 11 ++ .../app/components/InlineEditableTitle.tsx | 87 ++++++++++++ .../editor/ElementSelectionContext.tsx | 2 +- .../editor/useDomElementSelector.ts | 4 +- .../pages/AgentChat/bubbles/MessageBubble.tsx | 12 +- .../app/pages/Dashboard/DashboardToolbar.tsx | 8 +- .../Dashboard/canvas/DashboardCanvas.tsx | 2 + .../Dashboard/canvas/DashboardCardLayer.tsx | 8 ++ .../Dashboard/canvas/DashboardOverlays.tsx | 8 ++ .../app/pages/Dashboard/cards/AgentCard.tsx | 24 ++-- .../app/pages/Dashboard/controls/Minimap.tsx | 26 +++- .../pages/Dashboard/geometry/getCardRect.ts | 4 + .../hooks/state/useDashboardController.ts | 1 + .../hooks/state/useDashboardSelection.ts | 20 ++- .../pages/Workflows/AddToSchedulePopover.tsx | 87 ++++++++++++ .../src/app/pages/Workflows/EditAgentView.tsx | 55 ++++---- .../src/app/pages/Workflows/WorkflowCard.tsx | 130 +++++++++++++++++- .../app/pages/Workflows/WorkflowsHubCard.tsx | 121 ++++++++++++---- frontend/src/shared/state/agentsSlice.ts | 15 ++ .../src/shared/state/dashboardLayoutSlice.ts | 9 +- 22 files changed, 598 insertions(+), 88 deletions(-) create mode 100644 frontend/src/app/components/InlineEditableTitle.tsx create mode 100644 frontend/src/app/pages/Workflows/AddToSchedulePopover.tsx diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index ee3f8999..273e91b2 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -1520,6 +1520,7 @@ class AgentManager: # exit code 1 / Check stderr output for details", which masks # transient capacity issues. _stderr_buffer: list[str] = [] + _system_event_buffer: list[str] = [] def _stderr_cb(line: str) -> None: _stderr_buffer.append(line) @@ -2376,6 +2377,12 @@ class AgentManager: # Log system messages (MCP server status, errors, etc.) if isinstance(message, SystemMessage): raw = message.__dict__ if hasattr(message, '__dict__') else str(message) + try: + _system_event_buffer.append(json.dumps(raw, default=str)) + if len(_system_event_buffer) > 200: + del _system_event_buffer[:100] + except Exception: + pass logger.info(f"[MCP-DEBUG] SystemMessage: {raw}") if isinstance(message, StreamEvent): @@ -2949,6 +2956,7 @@ class AgentManager: _current_turn_emitted = False await asyncio.sleep(wait) _stderr_buffer.clear() + _system_event_buffer.clear() if session.sdk_session_id: options_kwargs["resume"] = session.sdk_session_id options = ClaudeAgentOptions(**options_kwargs) @@ -2991,7 +2999,10 @@ class AgentManager: # user can't recover by waiting, this is a tier-gate, not a rate # limit, so the UX matters. try: - _stderr_tail = "\n".join(_stderr_buffer[-50:]) + _stderr_tail = "\n".join([ + "\n".join(_stderr_buffer[-50:]), + "\n".join(_system_event_buffer[-50:]), + ]) except Exception: _stderr_tail = "" # If we already streamed a substantive assistant response this @@ -3169,6 +3180,31 @@ class AgentManager: "session_id": session_id, "message": error_msg.model_dump(mode="json"), }) + elif _is_transient_capacity_error(e, extra_text=_stderr_tail): + friendly_msg = ( + "provider_rate_limit: This model hit your account or " + "session rate limit. Wait until the reset time shown by " + "your provider, then send your message again, or switch " + "to a different model." + ) + try: + from backend.apps.service.client import submit_diagnostic + submit_diagnostic({ + "kind": "model_error", + "subkind": "rate_limit", + "model": session.model, + "provider": session.provider, + "connection_mode": getattr(load_settings(), "connection_mode", "own_key"), + "error_preview": (f"{e!s}\n{_stderr_tail}")[:600], + }) + except Exception: + logger.debug("submit_diagnostic transient_capacity failed", exc_info=True) + error_msg = Message(role="assistant", content=friendly_msg, branch_id=session.active_branch_id) + session.messages.append(error_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, + "message": error_msg.model_dump(mode="json"), + }) else: # Track unclassified agent failures too so we stop flying blind on them. try: diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 91901bc9..12dacccf 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -597,11 +597,21 @@ async def edit_agent_session(workflow_id: str): from backend.apps.agents.core.models import AgentConfig from backend.apps.agents.agent_manager import agent_manager steps_lines = "\n".join(f"{i+1}. {(s.label or '').strip() or (s.text or '')[:60]}\n Prompt: {s.text}" for i, s in enumerate(wf.steps)) + # A brand-new workflow ("+ New" in the hub) opens here with zero steps, so + # frame the agent as a builder rather than a fix-what-exists editor. + intro = ( + "Help the user iterate on it." + if wf.steps + else "This workflow is brand new and has no steps yet. Help the user " + "build it from scratch: ask what it should do, then add steps with " + "AddWorkflowStep." + ) + steps_block = f"Current steps:\n{steps_lines}\n\n" if wf.steps else "It has no steps yet.\n\n" system_prompt = ( f"You are the Edit Agent for the user's saved workflow \"{wf.title}\" " - f"(id: {wf.id}). Help the user iterate on it. The workflow's purpose: " + f"(id: {wf.id}). {intro} The workflow's purpose: " f"{wf.description or '(unspecified)'}.\n\n" - f"Current steps:\n{steps_lines}\n\n" + f"{steps_block}" "How to work:\n" "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 " diff --git a/backend/tests/test_free_trial.py b/backend/tests/test_free_trial.py index ac61df64..d553e044 100644 --- a/backend/tests/test_free_trial.py +++ b/backend/tests/test_free_trial.py @@ -48,6 +48,17 @@ def test_exhaustion_is_classified_and_not_retried(): assert not _is_transient_capacity_error(Exception("free_trial_exhausted")) +def test_generic_cli_failure_uses_sdk_system_events_for_rate_limits(): + system_event_tail = ( + '{"subtype":"api_retry","data":{"error_status":429,' + '"error":"rate_limit","max_retries":10}}' + ) + assert _is_transient_capacity_error( + Exception("Command failed with exit code 1"), + extra_text=system_event_tail, + ) + + def test_has_own_model_never_shadows_a_real_provider(): assert not _has_own_model(AppSettings(connection_mode="free-trial", free_trial_token="x")) assert not _has_own_model(AppSettings()) diff --git a/frontend/src/app/components/InlineEditableTitle.tsx b/frontend/src/app/components/InlineEditableTitle.tsx new file mode 100644 index 00000000..a749d896 --- /dev/null +++ b/frontend/src/app/components/InlineEditableTitle.tsx @@ -0,0 +1,87 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import InputBase from '@mui/material/InputBase'; +import type { SxProps, Theme } from '@mui/material/styles'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +interface Props { + // Current title as shown to the user; seeds the edit field. + value: string; + // Called with the trimmed new title only when it actually changed. + onCommit: (next: string) => void; + // Layout + text styling shared by the read-only text and the input so the + // two states line up (pass flex/font/color here). + sx?: SxProps; + placeholder?: string; + // Optional custom display node (e.g. the chat card's Typewriter); falls + // back to a plain Typography of `value` when omitted. + children?: React.ReactNode; +} + +// Click-to-rename title. Reads as plain text until clicked, then becomes an +// inline input that commits on Enter/blur and cancels on Escape. Lives on +// pointer-drag card headers, so it stops pointer propagation (+ data-no-drag) +// to avoid starting a card drag while editing. +export default function InlineEditableTitle({ value, onCommit, sx, placeholder, children }: Props) { + const c = useClaudeTokens(); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); + const inputRef = useRef(null); + + useEffect(() => { + if (editing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [editing]); + + const begin = useCallback(() => { setDraft(value); setEditing(true); }, [value]); + + const commit = useCallback(() => { + const t = draft.trim(); + if (t && t !== value) onCommit(t); + setEditing(false); + }, [draft, value, onCommit]); + + if (editing) { + return ( + e.stopPropagation()} + onChange={(e) => setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); commit(); } + else if (e.key === 'Escape') { e.preventDefault(); setEditing(false); } + }} + sx={{ ...sx, '& input::placeholder': { color: c.text.muted, opacity: 1 } }} + /> + ); + } + + return ( + e.stopPropagation()} + title="Click to rename" + sx={{ + minWidth: 0, cursor: 'text', borderRadius: 0.5, px: 0.25, mx: -0.25, + '&:hover': { bgcolor: c.bg.elevated }, + ...sx, + }} + > + {children ?? ( + + {value} + + )} + + ); +} diff --git a/frontend/src/app/components/editor/ElementSelectionContext.tsx b/frontend/src/app/components/editor/ElementSelectionContext.tsx index d3a77373..ece5a99c 100644 --- a/frontend/src/app/components/editor/ElementSelectionContext.tsx +++ b/frontend/src/app/components/editor/ElementSelectionContext.tsx @@ -10,7 +10,7 @@ export interface SelectedElement { computedStyles: Record; screenshot?: string; boundingRect: { x: number; y: number; width: number; height: number }; - semanticType?: 'agent-card' | 'message' | 'tool-call' | 'tool-group' | 'view-card' | 'browser-card' | 'dom-element'; + semanticType?: 'agent-card' | 'message' | 'tool-call' | 'tool-group' | 'view-card' | 'browser-card' | 'workflow-card' | 'workflows-hub-card' | 'dom-element'; semanticLabel?: string; semanticData?: Record; } diff --git a/frontend/src/app/components/editor/useDomElementSelector.ts b/frontend/src/app/components/editor/useDomElementSelector.ts index bf2df2fe..d0ea73ac 100644 --- a/frontend/src/app/components/editor/useDomElementSelector.ts +++ b/frontend/src/app/components/editor/useDomElementSelector.ts @@ -6,7 +6,7 @@ const SELECT_ATTR = 'data-select-type'; const SELECT_ID_ATTR = 'data-select-id'; const SELECT_META_ATTR = 'data-select-meta'; -const DRAG_SELECT_TYPES = ['agent-card', 'view-card', 'browser-card'] as const; +const DRAG_SELECT_TYPES = ['agent-card', 'view-card', 'browser-card', 'workflow-card', 'workflows-hub-card'] as const; const DRAG_SELECTOR = DRAG_SELECT_TYPES.map((t) => `[${SELECT_ATTR}="${t}"]`).join(','); export interface OverlayState { @@ -38,6 +38,8 @@ const SEMANTIC_LABELS: Record = { 'tool-group': 'Tool Group', 'view-card': 'View', 'browser-card': 'Browser', + 'workflow-card': 'Workflow', + 'workflows-hub-card': 'Workflows', }; function findSelectableAncestor(target: Element, excludeId?: string | null): Element | null { diff --git a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx index d9b20127..14dec53b 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx @@ -111,7 +111,17 @@ function parseOpenSwarmError(text: string, ctx?: OverflowContext): OpenSwarmErro ctaAction: 'upgrade', }; } - // Transient throttle: Anthropic's upstream 429/overload or our own pool-shed. Not the user's + if (/provider_rate_limit|account'?s rate limit|session rate limit|This request would exceed your account'?s rate limit/i.test(text)) { + const reset = text.match(/reset after ([^)\\.]+)/i)?.[1]; + return { + kind: 'network', + title: "You've hit this model's rate limit", + detail: reset + ? `This model can send more requests after ${reset}. Wait for that reset window, or switch to another model.` + : 'Wait for the reset window shown by your provider, or switch to another model.', + }; + } + // Transient throttle: Anthropic's upstream overload or our own pool-shed. Not the user's // fault and not a plan cap, so don't say "upgrade", just tell them it's busy. claude.ai-style. if (/rate_limit_error|free_pool_busy|overloaded_error|too many requests/i.test(text)) { return { diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index a1ec9173..8b163341 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -343,7 +343,8 @@ const DashboardToolbar = React.forwardRef( if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) return; const el = target instanceof Element ? target : (target as Node).parentElement; - if (el?.closest('[role="dialog"], [role="presentation"], .MuiModal-root, .MuiPopover-root')) { + + if (el?.closest('[role="dialog"], [role="presentation"], .MuiModal-root, .MuiPopover-root, [data-toolbar-pills]')) { return; } if (elementSelection?.selectMode && el?.closest('[data-select-type]')) { @@ -415,10 +416,7 @@ 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) { diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index fa965cd5..a449a853 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -289,6 +289,8 @@ const DashboardCanvas: React.FC = ({ cards={cards} viewCards={viewCards} browserCards={browserCards} + workflowCards={workflowCards} + workflowsHub={workflowsHub} focusedCardId={focusedCardId} shakeDirection={shakeDirection} neighborDirections={neighborDirections} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx index 75af0b0d..2ab00dfa 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx @@ -273,6 +273,14 @@ const DashboardCardLayer: React.FC = ({ zoom={zoom} panX={panX} panY={panY} + isSelected={selection.isSelected('workflows-hub')} + isHighlighted={highlightedCardId === 'workflows-hub'} + multiDragDelta={selection.isSelected('workflows-hub') ? multiDragDelta : null} + onCardSelect={onCardSelect} + onDragStart={onDragStart} + onDragMove={onDragMove} + onDragEnd={onDragEnd} + onBringToFront={onBringToFront} /> )} {Object.values(workflowCards).map((wc) => ( diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx index 8d786970..2a54af0b 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx @@ -9,6 +9,8 @@ import type { CardPosition, ViewCardPosition, BrowserCardPosition, + WorkflowCardPosition, + WorkflowsHubPosition, } from '@/shared/state/dashboardLayoutSlice'; import type { useCanvasControls } from '../hooks/interaction/useCanvasControls'; @@ -23,6 +25,8 @@ interface DashboardOverlaysProps { cards: Record; viewCards: Record; browserCards: Record; + workflowCards: Record; + workflowsHub: WorkflowsHubPosition | null; focusedCardId: string | null; shakeDirection: Direction | null; neighborDirections: NeighborDirections; @@ -52,6 +56,8 @@ const DashboardOverlays: React.FC = ({ cards, viewCards, browserCards, + workflowCards, + workflowsHub, focusedCardId, shakeDirection, neighborDirections, @@ -121,6 +127,8 @@ const DashboardOverlays: React.FC = ({ cards, viewCards, browserCards, + workflowCards, + workflowsHub, }} onMinimapPan={(px, py) => canvas.actions.setState({ panX: px, panY: py, zoom: canvas.zoom })} /> diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index ef4e35ba..6cdc1521 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -19,9 +19,11 @@ import { handleApproval, collapseSession, closeSession, + renameSession, } from '@/shared/state/agentsSlice'; import { displayChatTitle, isLegacyAutoName } from '@/shared/state/sessionDisplay'; import { Typewriter } from '@/app/components/feedback/Animated'; +import InlineEditableTitle from '@/app/components/InlineEditableTitle'; import { setCardPosition, setCardSize, @@ -789,16 +791,22 @@ const AgentCard: React.FC = ({ borderRadius: 1, }} > - dispatch(renameSession({ sessionId: session.id, name }))} + sx={{ flex: 1, color: c.text.primary, fontWeight: 600, fontSize: '0.95rem' }} > - {(t) => ( - - {t} - - )} - + + {(t) => ( + + {t} + + )} + + {/* Status speaks only when it needs the user; finished work sits quiet. The welcome chat hides its 'draft' label so the title reads clean. */} {session.status !== 'completed' && session.status !== 'stopped' && !session.is_welcome_draft && ( diff --git a/frontend/src/app/pages/Dashboard/controls/Minimap.tsx b/frontend/src/app/pages/Dashboard/controls/Minimap.tsx index 7a2b7ebb..4bdee3b1 100644 --- a/frontend/src/app/pages/Dashboard/controls/Minimap.tsx +++ b/frontend/src/app/pages/Dashboard/controls/Minimap.tsx @@ -1,6 +1,6 @@ import React, { useRef, useCallback, useMemo } from 'react'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice'; +import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice'; const MINIMAP_W = 200; const MINIMAP_H = 140; @@ -14,6 +14,8 @@ export interface MinimapProps { cards: Record; viewCards: Record; browserCards: Record; + workflowCards: Record; + workflowsHub: WorkflowsHubPosition | null; onPan: (panX: number, panY: number) => void; } @@ -22,12 +24,12 @@ interface CardRect { y: number; width: number; height: number; - type: 'agent' | 'view' | 'browser'; + type: 'agent' | 'view' | 'browser' | 'workflow' | 'workflows-hub'; } const Minimap: React.FC = ({ panX, panY, zoom, viewportRef, - cards, viewCards, browserCards, + cards, viewCards, browserCards, workflowCards, workflowsHub, onPan, }) => { const c = useClaudeTokens(); @@ -45,8 +47,20 @@ const Minimap: React.FC = ({ for (const bc of Object.values(browserCards)) { result.push({ x: bc.x, y: bc.y, width: bc.width, height: bc.height, type: 'browser' }); } + for (const wc of Object.values(workflowCards)) { + result.push({ x: wc.x, y: wc.y, width: wc.width, height: wc.height, type: 'workflow' }); + } + if (workflowsHub) { + result.push({ + x: workflowsHub.x, + y: workflowsHub.y, + width: workflowsHub.width, + height: workflowsHub.height, + type: 'workflows-hub', + }); + } return result; - }, [cards, viewCards, browserCards]); + }, [cards, viewCards, browserCards, workflowCards, workflowsHub]); const layout = useMemo(() => { const vp = viewportRef.current; @@ -128,11 +142,13 @@ const Minimap: React.FC = ({ window.addEventListener('mouseup', onUp); }, [minimapToCanvas]); - const typeColor = (type: 'agent' | 'view' | 'browser') => { + const typeColor = (type: CardRect['type']) => { switch (type) { case 'agent': return c.accent.primary; case 'view': return c.status.info; case 'browser': return c.status.success; + case 'workflow': return c.status.warning; + case 'workflows-hub': return c.status.warning; } }; diff --git a/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts b/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts index 1d80881f..bcb03883 100644 --- a/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts +++ b/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts @@ -27,6 +27,10 @@ export function getCardRect(id: string, type: CardType): const wc = layoutState.workflowCards[id]; if (!wc) return undefined; return { x: wc.x, y: wc.y, width: wc.width, height: wc.height }; + } else if (type === 'workflows-hub') { + const hub = layoutState.workflowsHub; + if (!hub) return undefined; + return { x: hub.x, y: hub.y, width: hub.width, height: hub.height }; } return undefined; } diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index 9f054123..2481bf11 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -55,6 +55,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { browserCards, notes, workflowCards, + workflowsHub, ); const { toolbarRef, toolbarOpen, setToolbarOpen, searchPaletteOpen, setSearchPaletteOpen, diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts index b5ad9d64..efa0ae93 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useRef, useEffect, RefObject } from 'react'; -import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition, WorkflowCardPosition } from '@/shared/state/dashboardLayoutSlice'; +import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice'; export type { CardType } from '@/shared/state/dashboardLayoutSlice'; import type { CardType } from '@/shared/state/dashboardLayoutSlice'; @@ -44,6 +44,7 @@ export function useDashboardSelection( browserCards: Record = {}, notes: Record = {}, workflowCards: Record = {}, + workflowsHub: WorkflowsHubPosition | null = null, ) { const [selectedIds, setSelectedIds] = useState>(new Map()); const [marquee, setMarquee] = useState(null); @@ -79,8 +80,9 @@ export function useDashboardSelection( for (const bc of Object.values(browserCards)) next.set(bc.browser_id, 'browser'); for (const n of Object.values(notes)) next.set(n.note_id, 'note'); for (const wc of Object.values(workflowCards)) next.set(wc.workflow_id, 'workflow'); + if (workflowsHub) next.set('workflows-hub', 'workflows-hub'); setSelectedIds(next); - }, [cards, viewCards, browserCards, notes, workflowCards]); + }, [cards, viewCards, browserCards, notes, workflowCards, workflowsHub]); const selectCard = useCallback( (id: string, type: CardType, shiftKey: boolean) => { @@ -176,6 +178,18 @@ export function useDashboardSelection( } } + if ( + workflowsHub && + rectsIntersect(rect, { + x: workflowsHub.x, + y: workflowsHub.y, + width: workflowsHub.width, + height: workflowsHub.height, + }) + ) { + intersecting.set('workflows-hub', 'workflows-hub'); + } + if (shiftKey) { const base = selectionBeforeMarqueeRef.current; const next = new Map(base); @@ -191,7 +205,7 @@ export function useDashboardSelection( return intersecting; }, - [cards, viewCards, browserCards, notes, workflowCards], + [cards, viewCards, browserCards, notes, workflowCards, workflowsHub], ); const handleCanvasMouseDown = useCallback( diff --git a/frontend/src/app/pages/Workflows/AddToSchedulePopover.tsx b/frontend/src/app/pages/Workflows/AddToSchedulePopover.tsx new file mode 100644 index 00000000..7e1e576d --- /dev/null +++ b/frontend/src/app/pages/Workflows/AddToSchedulePopover.tsx @@ -0,0 +1,87 @@ +import React, { useCallback } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Popover from '@mui/material/Popover'; +import CalendarMonthRounded from '@mui/icons-material/CalendarMonthRounded'; +import TuneRoundedIcon from '@mui/icons-material/TuneRounded'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch } from '@/shared/hooks'; +import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice'; +import { openWorkflowCard, updateWorkflow, type Workflow } from '@/shared/state/workflowsSlice'; +import { describeSchedule } from './scheduleUtils'; + +interface Props { + anchorEl: HTMLElement | null; + workflow: Workflow | null; + onClose: () => void; +} + +// Opens off an Un-scheduled workflow's "+" icon. Two paths: keep the cadence +// the workflow already carries (just flip enabled on) or open the scheduler +// to change it. Enabling moves the row into "Scheduled workflows" since +// isSchedulable keys off schedule.enabled. +export default function AddToSchedulePopover({ anchorEl, workflow, onClose }: Props) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + + // describeSchedule returns "Not scheduled" while disabled; preview the + // cadence as if it were on so "Keep" shows what it would commit to. + const summary = workflow ? describeSchedule({ ...workflow.schedule, enabled: true }) : ''; + + const keep = useCallback(() => { + if (!workflow) return; + dispatch(updateWorkflow({ + id: workflow.id, + patch: { schedule: { ...workflow.schedule, enabled: true } as any }, + ifMatch: workflow.updated_at || null, + })); + onClose(); + }, [dispatch, workflow, onClose]); + + const change = useCallback(() => { + if (!workflow) return; + dispatch(addWorkflowCard({ workflowId: workflow.id })); + dispatch(openWorkflowCard({ workflowId: workflow.id, view: 'scheduling' })); + onClose(); + }, [dispatch, workflow, onClose]); + + const rowSx = { + display: 'flex', alignItems: 'center', gap: 0.9, + px: 0.75, py: 0.65, borderRadius: `${c.radius.md}px`, cursor: 'pointer', + '&:hover': { bgcolor: c.bg.elevated }, + }; + const iconSx = { + width: 28, height: 28, borderRadius: `${c.radius.md}px`, flexShrink: 0, + bgcolor: c.accent.primary + '18', color: c.accent.primary, + display: 'flex', alignItems: 'center', justifyContent: 'center', + }; + + return ( + + + ADD TO SCHEDULE + + + + + Keep this schedule + {summary} + + + + + + Change schedule… + Pick a different time + + + + ); +} diff --git a/frontend/src/app/pages/Workflows/EditAgentView.tsx b/frontend/src/app/pages/Workflows/EditAgentView.tsx index c83b394a..03f8038e 100644 --- a/frontend/src/app/pages/Workflows/EditAgentView.tsx +++ b/frontend/src/app/pages/Workflows/EditAgentView.tsx @@ -6,14 +6,14 @@ // 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 React, { useEffect, useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import BuildRounded from '@mui/icons-material/BuildRounded'; import KeyboardArrowDownRounded from '@mui/icons-material/KeyboardArrowDownRounded'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { clearFixSeed, updateWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice'; +import { clearFixSeed, type Workflow } from '@/shared/state/workflowsSlice'; import { fetchSession } from '@/shared/state/agentsSlice'; import { API_BASE, getAuthToken } from '@/shared/config'; import StepList from './StepList'; @@ -23,9 +23,12 @@ interface Props { workflow: Workflow; steps: Workflow['steps']; isFixMode?: boolean; + // The card header (in WorkflowCard) renders the model/time subtitle and the + // Save Workflow button, so it needs the live edit-agent session id. + onEditSessionIdChange?: (sessionId: string | null) => void; } -export default function EditAgentView({ workflow, steps, isFixMode = false }: Props) { +export default function EditAgentView({ workflow, steps, isFixMode = false, onEditSessionIdChange }: Props) { const c = useClaudeTokens(); const dispatch = useAppDispatch(); const card = useAppSelector((s) => s.workflows.openCards[workflow.id]); @@ -34,6 +37,8 @@ export default function EditAgentView({ workflow, steps, isFixMode = false }: Pr const [fixPrefixExpanded, setFixPrefixExpanded] = useState(false); const [editSessionId, setEditSessionId] = useState(workflow.edit_agent_session_id || null); const [seedSent, setSeedSent] = useState(false); + // Surface the live session id to the card header (Save button + model/time). + useEffect(() => { onEditSessionIdChange?.(editSessionId); }, [editSessionId, onEditSessionIdChange]); // Clear the fix seed after the view unmounts so re-entering edit_agent // (without going through Fix-with-Agent) doesn't re-show the prefix. useEffect(() => () => { dispatch(clearFixSeed(workflow.id)); }, [dispatch, workflow.id]); @@ -73,7 +78,11 @@ export default function EditAgentView({ workflow, steps, isFixMode = false }: Pr } const seed = isFixMode && fixSeed ? `The most recent run failed on Step ${fixSeed.stepIdx + 1} (${fixSeed.stepLabel}). Error: ${fixSeed.error}\n\nWalk me through what likely went wrong and propose a concrete prompt change for that step.` - : 'Greet me briefly, then ask: "How would you like to modify the workflow (e.g. filter out spam emails before summarizing)?"'; + // A brand-new workflow has no steps yet, so open in build mode ("what + // should this do?") rather than the modify-an-existing-flow prompt. + : steps.length === 0 + ? 'Greet me briefly, then ask: "What should this workflow do?"' + : 'Greet me briefly, then ask: "How would you like to modify the workflow (e.g. filter out spam emails before summarizing)?"'; setSeedSent(true); (async () => { try { @@ -85,37 +94,24 @@ export default function EditAgentView({ workflow, steps, isFixMode = false }: Pr }); } catch { /* best-effort */ } })(); - }, [editSessionId, editSession, seedSent, isFixMode, fixSeed]); - - const onDone = useCallback(() => { - dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } })); - }, [dispatch, workflow.id]); + }, [editSessionId, editSession, seedSent, isFixMode, fixSeed, steps.length]); 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. */} + the chat. The header's Save Workflow button drops back to the card. */} - - setStepsOpen((x) => !x)} - role="button" - sx={{ - 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'}) - - - - Done - + setStepsOpen((x) => !x)} + role="button" + sx={{ + 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'}) {stepsOpen && ( @@ -187,4 +183,3 @@ function FixPrefixCard({ seed, expanded, onToggle }: { seed: { stepIdx: number; ); } - diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index 7e40cdde..80d79f9d 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -9,11 +9,13 @@ import CloseIcon from '@mui/icons-material/Close'; import HistoryIcon from '@mui/icons-material/HistoryRounded'; import PlayArrowIcon from '@mui/icons-material/PlayArrowRounded'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined'; import InputBase from '@mui/material/InputBase'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { closeWorkflowCard, + deleteWorkflow, fetchRuns, openWorkflowCard as openWorkflowCardAction, rekeyOpenCard, @@ -38,11 +40,12 @@ import { HistoryDetail, HistoryList, PreviewView, SavedView } from './WorkflowCa import { CompletedView, FailedView, RunningView } from './WorkflowCardLiveViews'; import SchedulingView from './SchedulingView'; import EditAgentView from './EditAgentView'; +import InlineEditableTitle from '@/app/components/InlineEditableTitle'; import StopRounded from '@mui/icons-material/StopRounded'; import PauseRounded from '@mui/icons-material/PauseRounded'; import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun } from './workflowVisuals'; import { store } from '@/shared/state/store'; -import { getAgentWorkTime } from '@/shared/agentWorkTime'; +import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime'; type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; @@ -193,6 +196,25 @@ const WorkflowCard: React.FC = ({ const isDraft = card?.view === 'preview' && !workflow; const steps = (workflow?.steps || card?.draft?.steps || []) as Workflow['steps']; + // Edit-agent chrome lives in the card header: the model/time subtitle and + // the Save Workflow button. EditAgentView owns the live session and reports + // its id up here. The Save button pulses once when a turn finishes adding a + // step, nudging the user that there's something worth saving. + const isEditAgentView = card?.view === 'edit_agent' || card?.view === 'fix_agent'; + const [editSessionId, setEditSessionId] = useState(null); + const editSession = useAppSelector((s) => editSessionId ? s.agents.sessions[editSessionId] : undefined); + const [savePulseNonce, setSavePulseNonce] = useState(0); + const prevEditStatusRef = useRef(undefined); + useEffect(() => { + const status = editSession?.status; + const prev = prevEditStatusRef.current; + const wasRunning = prev === 'running' || prev === 'waiting_approval'; + if (wasRunning && status === 'completed' && steps.length > 0) { + setSavePulseNonce((n) => n + 1); + } + prevEditStatusRef.current = status; + }, [editSession?.status, steps.length]); + // ---- Card drag via title bar ---- const DRAG_THRESHOLD = 3; const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null); @@ -348,9 +370,15 @@ const WorkflowCard: React.FC = ({ // user can re-open from the Workflows hub. A confirm dialog here was // more friction than value (users clicked through it without reading). const onClose = useCallback(() => { + // A 0-step workflow can't run or be scheduled, so a "+ New" card the user + // opened and abandoned (without the build agent adding any steps) would + // just litter the hub. Delete it on close rather than orphan it. + if (workflow && (workflow.steps?.length ?? 0) === 0) { + dispatch(deleteWorkflow(workflow.id)); + } dispatch(closeWorkflowCard(workflowId)); dispatch(removeWorkflowCard(workflowId)); - }, [dispatch, workflowId]); + }, [dispatch, workflowId, workflow]); // ---- Display calculations ---- const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; @@ -472,14 +500,63 @@ const WorkflowCard: React.FC = ({ /> ) : ( <> - - {title} - + {workflow ? ( + dispatch(updateWorkflow({ id: workflow.id, patch: { title: name }, ifMatch: workflow.updated_at || null }))} + sx={{ flex: '0 1 auto', fontWeight: 600, fontSize: '0.95rem', color: c.text.primary, letterSpacing: '-0.005em' }} + /> + ) : ( + + {title} + + )} )} {runs && runs.length > 0 && } + {!isDraft && workflow && isEditAgentView && ( + 0 ? 'Save the workflow and close the editor' : 'Add at least one step before saving'}> + { + e.stopPropagation(); + if (steps.length === 0) { setRunToast('Add at least one step to your workflow first.'); return; } + dispatch(updateWorkflowCard({ workflowId, patch: { view: 'saved' } })); + }} + onPointerDown={(e) => e.stopPropagation()} + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.5, + fontSize: '0.78rem', fontWeight: 700, + px: 1.1, py: 0.5, + borderRadius: `${c.radius.md}px`, + cursor: 'pointer', + ...(steps.length > 0 ? { + color: '#fff', + bgcolor: c.accent.primary, + border: `1px solid ${c.accent.primary}`, + boxShadow: `0 0 0 0 ${c.accent.primary}00`, + animation: savePulseNonce > 0 ? `workflow-save-pulse-${savePulseNonce} 0.95s ease-out 1` : 'none', + [`@keyframes workflow-save-pulse-${savePulseNonce}`]: { + '0%': { boxShadow: `0 0 0 0 ${c.accent.primary}55`, transform: 'scale(1)' }, + '55%': { boxShadow: `0 0 0 8px ${c.accent.primary}00`, transform: 'scale(1.035)' }, + '100%': { boxShadow: `0 0 0 0 ${c.accent.primary}00`, transform: 'scale(1)' }, + }, + '&:hover': { filter: 'brightness(1.05)' }, + } : { + color: c.text.muted, + bgcolor: c.bg.elevated, + border: `1px solid ${c.border.subtle}`, + }), + }} + > + + Save Workflow + + + )} = ({ + {/* Edit-agent view borrows the chat card's subtitle: model + live work + time, so the workflow editor reads the same as a normal chat. */} + {!isDraft && workflow && isEditAgentView && ( + + + + )} + {/* Action bar matches new design: History + Run flush-right (Edit moved to footer). The flex spacer is the empty left side; History is a quiet text link, Run is the accent pill. */} @@ -645,7 +730,7 @@ const WorkflowCard: React.FC = ({ )} {(card.view === 'edit_agent' || card.view === 'fix_agent') && workflow && ( - + )} @@ -743,6 +828,39 @@ function StatusPill({ view, workflow, runs }: { view: string; workflow: Workflow ); } +// Mirrors the dashboard chat card's "Claude Sonnet 4.6 4s" subtitle, but for +// the live edit-agent session driving the workflow editor. Self-ticks at 1Hz +// while the agent is working so the time counts up like a normal chat. +function EditAgentSubtitle({ session }: { session: import('@/shared/state/agentsSlice').AgentSession | undefined }) { + const c = useClaudeTokens(); + const modelsByProvider = useAppSelector((s) => s.models.byProvider); + const [, setTick] = useState(0); + const status = session?.status; + useEffect(() => { + if (status !== 'running' && status !== 'waiting_approval') return; + const id = setInterval(() => setTick((t) => (t + 1) & 0xffff), 1000); + return () => clearInterval(id); + }, [status]); + const modelLabel = React.useMemo(() => { + const value = session?.model; + if (!value) return ''; + for (const list of Object.values(modelsByProvider || {})) { + for (const m of (list as any[]) || []) { + if (m.value === value) return m.label || value; + } + } + return value; + }, [session?.model, modelsByProvider]); + if (!session) return null; + const lastSec = getAgentWorkTime(session.messages || [], session.status || '').last; + return ( + + {modelLabel && {modelLabel}} + {lastSec > 0 && {fmtSeconds(lastSec)}} + + ); +} + function SubtitleRow({ workflow, runs, fallbackModel, fallbackMode, fallbackSourceSessionId }: { workflow: Workflow | null; runs: import('@/shared/state/workflowsSlice').WorkflowRun[] | null; diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx index bfada286..33199f8b 100644 --- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -19,7 +19,7 @@ import { setWorkflowsHubPosition, setWorkflowsHubSize, } from '@/shared/state/dashboardLayoutSlice'; -import { openWorkflowCard, fetchPausedState, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice'; +import { openWorkflowCard, createWorkflow, fetchPausedState, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice'; import type { Workflow } from '@/shared/state/workflowsSlice'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; @@ -27,6 +27,7 @@ import Switch from '@mui/material/Switch'; import Tooltip from '@mui/material/Tooltip'; import { useEffect } from 'react'; import ScheduleCalendar from './ScheduleCalendar'; +import AddToSchedulePopover from './AddToSchedulePopover'; import { WEEKDAY_LABEL, addDays, sameDay, startOfMonthGrid } from './scheduleUtils'; type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; @@ -61,6 +62,14 @@ interface Props { zoom?: number; panX?: number; panY?: number; + isSelected?: boolean; + isHighlighted?: boolean; + multiDragDelta?: { dx: number; dy: number } | null; + onCardSelect?: (id: string, type: 'workflows-hub', shiftKey: boolean) => void; + onDragStart?: (id: string, type: 'workflows-hub') => void; + onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void; + onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void; + onBringToFront?: (id: string, type: 'workflows-hub') => void; } type CalendarView = 'Week' | 'Month' | 'List'; @@ -116,6 +125,8 @@ function TimeSavedBadge() { const WorkflowsHubCard: React.FC = ({ cardX, cardY, cardWidth, cardHeight, cardZOrder = 0, zoom = 1, panX = 0, panY = 0, + isSelected = false, isHighlighted = false, multiDragDelta = null, + onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront, }) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); @@ -138,6 +149,10 @@ const WorkflowsHubCard: React.FC = ({ // consistent. closeMenu wipes both state + DOM-focus. const [sidebarCtxMenu, setSidebarCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null); const closeSidebarCtxMenu = useCallback(() => setSidebarCtxMenu(null), []); + // Anchored off an Un-scheduled row's "+" icon: offers keep-this-cadence vs + // open-the-scheduler, then enabling moves the row into Scheduled. + const [schedulePopover, setSchedulePopover] = useState<{ anchorEl: HTMLElement; workflow: Workflow } | null>(null); + const closeSchedulePopover = useCallback(() => setSchedulePopover(null), []); // "Scheduled" = the workflow has a real cadence configured at any // point (even if currently paused via the checkbox). Filtering by @@ -155,14 +170,18 @@ const WorkflowsHubCard: React.FC = ({ dispatch(openWorkflowCard({ workflowId: wid, view: 'saved' })); }, [dispatch]); - const onNew = useCallback(() => { - const tempId = `draft-${Date.now()}`; - dispatch(addWorkflowCard({ workflowId: tempId })); - dispatch(openWorkflowCard({ - workflowId: tempId, - view: 'preview', - draft: { title: 'New workflow', description: 'Describe what this workflow should do.', steps: [{ id: 'step-1', text: '' }] }, - })); + // A from-scratch workflow has no steps to convert, so skip the chat->workflow + // PreviewView (Schedule prompt / blank bullet) and drop straight into the + // Edit Agent build chat: the user describes it, the agent writes the steps. + // The workflow is created server-side first so the embedded edit-agent + // session has a real id to attach to; an abandoned (still 0-step) one is + // cleaned up on card close (WorkflowCard.onClose). + const onNew = useCallback(async () => { + const result = await dispatch(createWorkflow({ title: 'New workflow', steps: [] })); + if (!createWorkflow.fulfilled.match(result)) return; + const wf = result.payload; + dispatch(addWorkflowCard({ workflowId: wf.id })); + dispatch(openWorkflowCard({ workflowId: wf.id, view: 'edit_agent' })); }, [dispatch]); // ---- Card drag via header ---- @@ -171,6 +190,7 @@ const WorkflowsHubCard: React.FC = ({ const [isDragging, setIsDragging] = useState(false); const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null); const didDrag = useRef(false); + const justDraggedRef = useRef(false); const panRef = useRef({ panX, panY }); panRef.current = { panX, panY }; @@ -190,8 +210,9 @@ const WorkflowsHubCard: React.FC = ({ }; didDrag.current = false; setIsDragging(true); + onDragStart?.('workflows-hub', 'workflows-hub'); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); - }, [cardX, cardY]); + }, [cardX, cardY, onDragStart]); const onHeaderPointerMove = useCallback((e: React.PointerEvent) => { if (!dragState.current) return; @@ -202,11 +223,14 @@ const WorkflowsHubCard: React.FC = ({ const z = zoomRef.current; const panDx = (panRef.current.panX - dragState.current.startPanX) / z; const panDy = (panRef.current.panY - dragState.current.startPanY) / z; + const dx = rawDx / z - panDx; + const dy = rawDy / z - panDy; setLocalDragPos({ - x: dragState.current.origX + rawDx / z - panDx, - y: dragState.current.origY + rawDy / z - panDy, + x: dragState.current.origX + dx, + y: dragState.current.origY + dy, }); - }, []); + onDragMove?.(dx, dy, e.clientX, e.clientY); + }, [onDragMove]); const onHeaderPointerUp = useCallback((e: React.PointerEvent) => { if (!dragState.current) return; @@ -216,6 +240,8 @@ const WorkflowsHubCard: React.FC = ({ const dx = (e.clientX - dragState.current.startX) / z - panDx; const dy = (e.clientY - dragState.current.startY) / z - panDy; if (didDrag.current) { + justDraggedRef.current = true; + setTimeout(() => { justDraggedRef.current = false; }, 0); let finalX = dragState.current.origX + dx; let finalY = dragState.current.origY + dy; if (!e.shiftKey) { @@ -224,12 +250,13 @@ const WorkflowsHubCard: React.FC = ({ } dispatch(setWorkflowsHubPosition({ x: finalX, y: finalY })); } + onDragEnd?.(dx, dy, didDrag.current); dragState.current = null; didDrag.current = false; setLocalDragPos(null); setIsDragging(false); (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); - }, [dispatch]); + }, [dispatch, onDragEnd]); // ---- Resize ---- const resizeRef = useRef<{ dir: ResizeDir; sx0: number; sy0: number; ox: number; oy: number; ow: number; oh: number } | null>(null); @@ -278,14 +305,42 @@ const WorkflowsHubCard: React.FC = ({ (e.target as HTMLElement).releasePointerCapture(e.pointerId); }, [compute, dispatch]); - const dx = localResize?.x ?? localDragPos?.x ?? cardX; - const dy = localResize?.y ?? localDragPos?.y ?? cardY; + const mdDx = (!isDragging && !isResizing && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; + const mdDy = (!isDragging && !isResizing && isSelected && multiDragDelta) ? multiDragDelta.dy : 0; + const dx = (localResize?.x ?? localDragPos?.x ?? cardX) + mdDx; + const dy = (localResize?.y ?? localDragPos?.y ?? cardY) + mdDy; const dw = localResize?.w ?? cardWidth; const dh = localResize?.h ?? cardHeight; + const border = isHighlighted + ? `2px solid ${c.accent.primary}` + : isSelected + ? '2px solid #3b82f6' + : `1px solid ${c.border.medium}`; + const shadow = isHighlighted + ? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35, 0 0 40px ${c.accent.primary}15` + : (isDragging || isResizing) + ? c.shadow.lg + : isSelected + ? `0 0 0 1px #3b82f6, ${c.shadow.md}` + : c.shadow.md; + const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta); return ( { + const target = e.target as HTMLElement; + if (target.closest('[data-no-drag]')) return; + onBringToFront?.('workflows-hub', 'workflows-hub'); + }} + onClick={(e: React.MouseEvent) => { + if (justDraggedRef.current) return; + const target = e.target as HTMLElement; + if (target.closest('[data-no-drag]')) return; + onCardSelect?.('workflows-hub', 'workflows-hub', e.shiftKey); + }} sx={{ position: 'absolute', contain: 'layout style', @@ -295,13 +350,13 @@ const WorkflowsHubCard: React.FC = ({ width: dw, height: dh, bgcolor: c.bg.surface, - border: `1px solid ${c.border.medium}`, + border, borderRadius: `${c.radius.lg}px`, - boxShadow: (isDragging || isResizing) ? c.shadow.lg : c.shadow.md, + boxShadow: shadow, display: 'flex', flexDirection: 'column', zIndex: (isDragging || isResizing) ? 999999 : cardZOrder, - transition: (isDragging || isResizing) ? 'none' : 'box-shadow 0.3s ease', + transition: noTransition ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease', '&:hover .resize-handle': { opacity: 1 }, }} > @@ -355,7 +410,6 @@ const WorkflowsHubCard: React.FC = ({ '&:hover': { borderColor: c.accent.primary, color: c.accent.primary }, }} > - New @@ -445,7 +499,7 @@ const WorkflowsHubCard: React.FC = ({ match(w.title, search))} onPick={onSelectWorkflow} scheduled onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} /> - match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} /> + match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} onSchedule={(wf, el) => setSchedulePopover({ anchorEl: el, workflow: wf })} /> )} @@ -495,6 +549,13 @@ const WorkflowsHubCard: React.FC = ({ + {/* "+" on an Un-scheduled row -> keep cadence or open the scheduler */} + + {/* Resize handles */} {HANDLE_DEFS.map(({ dir, sx }) => ( vo ); } -function SidebarSection({ title, items, onPick, scheduled, onContext }: { +function SidebarSection({ title, items, onPick, scheduled, onContext, onSchedule }: { title: string; items: Workflow[]; onPick: (id: string) => void; scheduled: boolean; onContext: (workflow: Workflow, e: React.MouseEvent) => void; + // Only the Un-scheduled section wires this: clicking the "+" opens the + // add-to-schedule popover anchored to the icon. + onSchedule?: (workflow: Workflow, anchorEl: HTMLElement) => void; }) { const c = useClaudeTokens(); const dispatch = useAppDispatch(); @@ -599,7 +663,18 @@ function SidebarSection({ title, items, onPick, scheduled, onContext }: { ) : ( - + + { e.stopPropagation(); onSchedule?.(w, e.currentTarget); }} + sx={{ + width: 16, height: 16, borderRadius: '4px', flexShrink: 0, + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + color: c.text.muted, cursor: 'pointer', + '&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated }, + }}> + + + )} {w.title} diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index d64f52df..c4af374c 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -386,6 +386,21 @@ export const updateSystemPrompt = createAsyncThunk( } ); +export const renameSession = createAsyncThunk( + 'agents/rename', + async ({ sessionId, name }: { sessionId: string; name: string }, { dispatch }) => { + // Optimistic local update; the backend echoes the new name back over + // the agent:status broadcast, which keeps every open card in sync. + dispatch(updateSessionName({ sessionId, name })); + await fetch(`${AGENTS_API}/sessions/${sessionId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + return { sessionId, name }; + } +); + export const updateThinkingLevel = createAsyncThunk( 'agents/updateThinkingLevel', async ({ sessionId, level }: { sessionId: string; level: 'off' | 'low' | 'medium' | 'high' | 'auto' }) => { diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index f31b43ac..7068096f 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -27,7 +27,7 @@ export const GRID_GAP = 24; const GRID_ORIGIN = { x: 40, y: 100 }; const GRID_COLS_FALLBACK = 4; -export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow'; +export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub'; export interface CardPosition { session_id: string; @@ -1080,7 +1080,7 @@ const dashboardLayoutSlice = createSlice({ moveCards( state, action: PayloadAction<{ - items: Array<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' | 'workflow' }>; + items: Array<{ id: string; type: CardType }>; dx: number; dy: number; }>, @@ -1111,6 +1111,11 @@ const dashboardLayoutSlice = createSlice({ card.x += dx; card.y += dy; } + } else if (item.type === 'workflows-hub') { + if (state.workflowsHub) { + state.workflowsHub.x += dx; + state.workflowsHub.y += dy; + } } else { const card = state.browserCards[item.id]; if (card) {