From e19846fc5f9dd8ebc603dab15080c83651b7e555 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 19 May 2026 01:32:28 -0700 Subject: [PATCH] [eric] workflow card: fix can't-type-in-edit-view + edit button + ai descriptions --- backend/apps/dashboards/models.py | 10 + backend/apps/workflows/workflows.py | 115 +++-- .../src/app/pages/Dashboard/Dashboard.tsx | 99 +++- .../src/app/pages/Workflows/ActionsFacet.tsx | 65 ++- .../pages/Workflows/ConfigurePanelCard.tsx | 158 +++++++ .../src/app/pages/Workflows/GeneralFacet.tsx | 89 +++- .../src/app/pages/Workflows/ScheduleFacet.tsx | 424 +++++++----------- frontend/src/app/pages/Workflows/StepList.tsx | 60 ++- .../src/app/pages/Workflows/WorkflowCard.tsx | 109 +++-- .../pages/Workflows/WorkflowCardSubviews.tsx | 80 ++-- .../app/pages/Workflows/WorkflowEditViews.tsx | 50 ++- .../src/shared/hooks/useKeyboardShortcuts.ts | 35 +- .../src/shared/state/dashboardLayoutSlice.ts | 100 +++++ 13 files changed, 911 insertions(+), 483 deletions(-) create mode 100644 frontend/src/app/pages/Workflows/ConfigurePanelCard.tsx diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index c29c75c7..e90c2e1d 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -66,6 +66,15 @@ class WorkflowsHubPosition(BaseModel): height: float = 640 +class ConfigurePanelPosition(BaseModel): + """Floating Action-Library panel tethered to a workflow card.""" + workflow_id: str + x: float = 0 + y: float = 0 + width: float = 580 + height: float = 600 + + class DashboardLayout(BaseModel): cards: dict[str, CardPosition] = Field(default_factory=dict) view_cards: dict[str, ViewCardPosition] = Field(default_factory=dict) @@ -73,6 +82,7 @@ class DashboardLayout(BaseModel): workflow_cards: dict[str, WorkflowCardPosition] = Field(default_factory=dict) workflows_hub: Optional[WorkflowsHubPosition] = None notes: dict[str, NotePosition] = Field(default_factory=dict) + configure_panels: dict[str, ConfigurePanelPosition] = Field(default_factory=dict) expanded_session_ids: list[str] = Field(default_factory=list) diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 43d9b1bd..3dafb74c 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -138,63 +138,126 @@ async def create_workflow(body: WorkflowCreate): wf.icon = _derive_icon(wf) if wf.schedule.enabled: wf.next_run_at = scheduler.compute_next_fire(wf) - # AI-generated description when the caller didn't supply one. Best- - # effort via the user's configured aux model; on failure we leave - # description empty so the UI just hides the row rather than showing - # a fake placeholder. Doesn't block create — caller gets the workflow - # back, and a background task fills the description in seconds. - if not (wf.description or "").strip(): - try: - wf.description = await _generate_description(wf) - except Exception: - pass + # Force-generate title + description from the steps in a single aux + # call. Previously we only filled missing description, leaving stale + # session names ("Inbox check") as titles. One round-trip, both + # fields, overwrites whatever shallow draft the FE sent. + try: + title, description = await _generate_title_and_description(wf) + if title: + wf.title = title + if description: + wf.description = description + except Exception: + pass storage.save_workflow(wf) scheduler.kick() return _enriched(wf) -async def _generate_description(wf: Workflow) -> str: - """One aux-model call: summarize steps into a one-paragraph blurb. +async def _generate_title_and_description(wf: Workflow) -> tuple[str, str]: + """Single aux-model call returning (title, description). - Returns "" on any failure so the caller can write the result back - unconditionally. Never raises. + Uses strict JSON output so both fields come back in one round-trip. + Returns ("", "") on any failure so the caller can write back + unconditionally without dropping the workflow create. """ if not wf.steps: - return "" + return "", "" try: from backend.apps.agents.providers.registry import resolve_aux_model from backend.apps.agents.providers.registry import get_anthropic_client_for_model from backend.apps.settings.settings import load_settings as _ls except Exception: - return "" + return "", "" settings = _ls() try: aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku") client = get_anthropic_client_for_model(settings, aux_model) except Exception: - return "" + return "", "" steps_lines = "\n".join(f"{i+1}. {s.text}" for i, s in enumerate(wf.steps) if s.text) prompt = ( - "Write one short paragraph (2-3 sentences, under 50 words) that " - "describes what this workflow does, in plain English. No bullet " - "points, no preamble like 'This workflow...'. Just the description.\n\n" - f"Title: {wf.title}\n\n" + "You name and describe a saved automation routine that the user " + "can re-run later. The routine is defined ONLY by the numbered " + "steps below; treat those as the user's instructions to the " + "agent.\n\n" + "Return STRICT JSON, nothing else, no code fence:\n" + " {\"title\": string, \"description\": string}\n\n" + "title rules:\n" + "- 2 to 5 words, Title Case\n" + "- Starts with a verb-noun pair when possible (e.g. \"Summarize " + "Daily Emails\")\n" + "- No emoji, no quotes, no trailing punctuation\n\n" + "description rules:\n" + "- 1 to 2 sentences, under 30 words total\n" + "- Describes the concrete WORK the routine performs for the user, " + "not metadata about itself. Examples of GOOD output:\n" + " \"Reads recent Gmail, ranks urgency, and emails you a PDF " + "digest each Sunday at 9am.\"\n" + " \"Pulls today's calendar plus inbox, writes a Notion brief, " + "and texts you the link.\"\n" + "- Examples of BAD output you MUST AVOID verbatim:\n" + " \"This is an AI-generated description...\"\n" + " \"Auto-generated description used to wrap workflows...\"\n" + " Any sentence that talks about the description itself\n" + "- Start with a verb. Do NOT start with \"This\", \"A\", \"An\", " + "\"The workflow\", \"This routine\".\n\n" f"Steps:\n{steps_lines}" ) + import json + import re as _re + + def _extract_json_object(s: str) -> Optional[dict]: + """Find the first {...} block and json.loads it. Handles code + fences, prose preambles, and trailing chatter that some aux + models like to add.""" + s = s.strip() + if s.startswith("```"): + s = _re.sub(r"^```(?:json)?\s*", "", s, flags=_re.IGNORECASE) + s = _re.sub(r"\s*```\s*$", "", s) + # Greedy brace match; falls through to direct json.loads if no + # braces are visible at all. + start = s.find("{") + end = s.rfind("}") + if start != -1 and end != -1 and end > start: + s = s[start : end + 1] + try: + return json.loads(s) + except Exception: + return None + try: + # Prefill the assistant turn with `{` so the model is steered into + # emitting JSON from the first token. The Anthropic API treats a + # trailing assistant message as a prefill; we'll glue it back on + # before parsing. resp = await client.messages.create( model=aux_model, - max_tokens=160, - messages=[{"role": "user", "content": prompt}], + max_tokens=240, + messages=[ + {"role": "user", "content": prompt}, + {"role": "assistant", "content": "{"}, + ], ) text = "" if isinstance(resp.content, list): for block in resp.content: if getattr(block, "type", None) == "text": text += getattr(block, "text", "") - return text.strip()[:500] - except Exception: - return "" + raw = "{" + text.strip() if not text.strip().startswith("{") else text.strip() + data = _extract_json_object(raw) + if not data: + logger.warning("description gen: failed to parse aux model output: %s", raw[:400]) + return "", "" + title = (data.get("title") or "").strip()[:80] + description = (data.get("description") or "").strip()[:500] + if not description: + logger.warning("description gen: empty description from aux model. Raw: %s", raw[:400]) + return title, description + except Exception as e: + logger.warning("description gen: aux model call failed: %s", e) + return "", "" def _last_run_cost(wid: str) -> float: diff --git a/frontend/src/app/pages/Dashboard/Dashboard.tsx b/frontend/src/app/pages/Dashboard/Dashboard.tsx index 3a9071f6..cfe9cee9 100644 --- a/frontend/src/app/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/app/pages/Dashboard/Dashboard.tsx @@ -67,6 +67,7 @@ import DirectionHints from './DirectionHints'; import DashboardToolbar from './DashboardToolbar'; import WorkflowCard from '@/app/pages/Workflows/WorkflowCard'; import WorkflowsHubCard from '@/app/pages/Workflows/WorkflowsHubCard'; +import ConfigurePanelCard from '@/app/pages/Workflows/ConfigurePanelCard'; import { captureDashboardThumbnail } from './captureDashboardThumbnail'; import { useCanvasControls } from './useCanvasControls'; import { useDashboardSelection } from './useDashboardSelection'; @@ -116,6 +117,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const workflowCards = useAppSelector((state) => state.dashboardLayout.workflowCards); const workflowItems = useAppSelector((state) => state.workflows.items); const workflowOpenCards = useAppSelector((state) => state.workflows.openCards); + const configurePanels = useAppSelector((state) => state.dashboardLayout.configurePanels); const workflowsHub = useAppSelector((state) => state.dashboardLayout.workflowsHub); const notes = useAppSelector((state) => state.dashboardLayout.notes); const pendingFocusNoteId = useAppSelector((state) => state.dashboardLayout.pendingFocusNoteId); @@ -379,7 +381,16 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true setTimeout(() => { const rect = getCardRect(id, type); if (rect) canvas.actions.fitToCards([rect], 1.15, true, type === 'browser' ? 0.8 : undefined); - setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150); + setTimeout(() => { + // Don't blur if the focused element is an input/textarea/ + // contentEditable inside the just-clicked card; the user is + // typing there and this blur kills the cursor mid-keystroke. + const active = document.activeElement as HTMLElement | null; + if (!active) return; + const tag = active.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return; + active.blur?.(); + }, 150); }, 100); }, [selection, getCardRect, canvas.actions, dispatch, expandedSessionIds]); @@ -455,7 +466,16 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true setTimeout(() => { const rect = getCardRect(id, type); if (rect) canvas.actions.fitToCards([rect], 1.15, true); - setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150); + setTimeout(() => { + // Don't blur if the focused element is an input/textarea/ + // contentEditable inside the just-clicked card; the user is + // typing there and this blur kills the cursor mid-keystroke. + const active = document.activeElement as HTMLElement | null; + if (!active) return; + const tag = active.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return; + active.blur?.(); + }, 150); }, 100); }, [getCardRect, canvas.actions, dispatch]); @@ -819,7 +839,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true skipInitialSave.current = false; return; } - const payload = { dashboardId, cards, viewCards, browserCards, workflowCards, workflowsHub, notes, expandedSessionIds }; + const payload = { dashboardId, cards, viewCards, browserCards, workflowCards, configurePanels, workflowsHub, notes, expandedSessionIds }; pendingSaveRef.current = payload; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { @@ -828,7 +848,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true saveTimerRef.current = null; captureNow(); }, 500); - }, [isActive, cards, viewCards, browserCards, workflowCards, workflowsHub, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]); + }, [isActive, cards, viewCards, browserCards, workflowCards, configurePanels, workflowsHub, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]); useEffect(() => { return () => { @@ -1175,7 +1195,16 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true setTimeout(() => { const rect = getCardRect(target.id, target.type); if (rect) canvas.actions.fitToCards([rect], 1.15, true); - setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150); + setTimeout(() => { + // Don't blur if the focused element is an input/textarea/ + // contentEditable inside the just-clicked card; the user is + // typing there and this blur kills the cursor mid-keystroke. + const active = document.activeElement as HTMLElement | null; + if (!active) return; + const tag = active.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return; + active.blur?.(); + }, 150); }, 100); }; @@ -1775,9 +1804,58 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true }); } - return [...agentTethers, ...browserTethers, ...workflowTethers]; + // Configure-panel tethers: each open configure panel is anchored to its + // workflow card so the user always sees which workflow's action surface + // they're editing, even after dragging things around. + const configureTethers: Array<{ key: string; path: string; labelX: number; labelY: number; label: string; fading: boolean }> = []; + for (const p of Object.values(configurePanels)) { + const wc = workflowCards[p.workflow_id]; + if (!wc) continue; + let srcX = wc.x, srcY = wc.y; + let dstX = p.x, dstY = p.y; + if (liveDragInfo) { + if (liveDragInfo.cardId === p.workflow_id) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } + } + const srcCx = srcX + wc.width / 2; + const dstCx = dstX + p.width / 2; + const srcAnchors: Anchor[] = [ + { x: srcX + wc.width, y: srcY + wc.height * 0.5, side: 'right' }, + { x: srcX, y: srcY + wc.height * 0.5, side: 'left' }, + { x: srcCx, y: srcY, side: 'top' }, + { x: srcCx, y: srcY + wc.height, side: 'bottom' }, + ]; + const dstAnchors: Anchor[] = [ + { x: dstX, y: dstY + p.height * 0.5, side: 'left' }, + { x: dstX + p.width, y: dstY + p.height * 0.5, side: 'right' }, + { x: dstCx, y: dstY, side: 'top' }, + { x: dstCx, y: dstY + p.height, side: 'bottom' }, + ]; + let bestSrc = srcAnchors[0], bestDst = dstAnchors[0]; + let bestDist = Infinity; + for (const sa of srcAnchors) { + for (const da of dstAnchors) { + const d = Math.hypot(sa.x - da.x, sa.y - da.y); + if (d < bestDist) { bestDist = d; bestSrc = sa; bestDst = da; } + } + } + const x1 = bestSrc.x, y1 = bestSrc.y; + const x2 = bestDst.x, y2 = bestDst.y; + const pathD = elbowPath(x1, y1, x2, y2); + const midX = x1 + (x2 - x1) / 2; + const midY = y1 + (y2 - y1) / 2; + configureTethers.push({ + key: `configure-${p.workflow_id}`, + path: pathD, + labelX: midX, + labelY: midY, + label: 'Configure', + fading: false, + }); + } + + return [...agentTethers, ...browserTethers, ...workflowTethers, ...configureTethers]; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]); + }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, configurePanels, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]); const dotSize = Math.max(1, 1.5 * canvas.zoom); const dotSpacing = 24 * canvas.zoom; @@ -2146,6 +2224,13 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true onBringToFront={handleBringToFront} /> ))} + {Object.values(configurePanels).map((p) => ( + + ))} {Object.values(notes).map((n) => ( void }) { const c = useClaudeTokens(); - // Configure only appears when freeze is on; "Don't freeze" hides it - // entirely so the surface doesn't lie about what's enabled. - const [configuring, setConfiguring] = useState(false); - const toggleSet = (set: string, on: boolean) => { - const next = on - ? Array.from(new Set([...draft.actions.configured_sets, set])) - : draft.actions.configured_sets.filter((s) => s !== set); - setDraft({ ...draft, actions: { ...draft.actions, configured_sets: next } }); + const dispatch = useAppDispatch(); + // Configure pops the Action Library out as a separate dashboard card + // tethered to this workflow (image #120). Lives in + // dashboardLayout.configurePanels keyed by workflow id; user can drag, + // resize, and X-close from there. + const configuring = useAppSelector((s) => Boolean(s.dashboardLayout.configurePanels[draft.id])); + const toggleConfigure = () => { + if (configuring) dispatch(closeConfigurePanel(draft.id)); + else dispatch(openConfigurePanel({ workflowId: draft.id })); }; + // If the user flips Freeze off while the popout is open, close it so + // the orphaned card doesn't keep listening to a workflow that no + // longer wants a frozen action set. + React.useEffect(() => { + if (!draft.actions.freeze && configuring) { + dispatch(closeConfigurePanel(draft.id)); + } + }, [draft.actions.freeze, draft.id, configuring, dispatch]); return ( @@ -53,39 +60,21 @@ export default function ActionsFacet({ draft, setDraft }: { draft: Workflow; set + {/* Configure only makes sense when actions are frozen: the user + is explicitly picking a curated subset. With "Don't freeze", + the agent inherits global settings, so there's nothing to + configure here. Auto-close the panel on un-freeze so a stale + popout doesn't outlive the toggle. */} {draft.actions.freeze && ( setConfiguring((v) => !v)} + onClick={toggleConfigure} role="button" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: LABEL_FS, color: configuring ? c.accent.primary : c.text.secondary, cursor: 'pointer', fontWeight: 500, '&:hover': { color: c.accent.primary } }}> {configuring ? '⚙ Configuring…' : '⚙ Configure'} )} - - {draft.actions.freeze && configuring && ( - - BUILT-IN ACTION SETS - {BUILT_IN_SETS.map((set) => ( - toggleSet(set, on)} /> - ))} - CUSTOM ACTION SETS - {CUSTOM_SETS.map((set) => ( - toggleSet(set, on)} /> - ))} - - )} - - ); -} - -function ActionSetRow({ set, enabled, onChange }: { set: string; enabled: boolean; onChange: (v: boolean) => void }) { - const c = useClaudeTokens(); - return ( - - {set} - onChange(e.target.checked)} /> ); } diff --git a/frontend/src/app/pages/Workflows/ConfigurePanelCard.tsx b/frontend/src/app/pages/Workflows/ConfigurePanelCard.tsx new file mode 100644 index 00000000..2db4947f --- /dev/null +++ b/frontend/src/app/pages/Workflows/ConfigurePanelCard.tsx @@ -0,0 +1,158 @@ +import React, { useCallback, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import CloseIcon from '@mui/icons-material/Close'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch } from '@/shared/hooks'; +import { + closeConfigurePanel, + setConfigurePanelPosition, + setConfigurePanelSize, + type ConfigurePanelPosition, +} from '@/shared/state/dashboardLayoutSlice'; +import Tools from '@/app/pages/Tools/Tools'; + +const MIN_W = 420; +const MIN_H = 320; +const EDGE = 6; + +export default function ConfigurePanelCard({ panel, zOrder }: { panel: ConfigurePanelPosition; zOrder: number }) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); + const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null); + const [localPos, setLocalPos] = useState<{ x: number; y: number } | null>(null); + const [localSize, setLocalSize] = useState<{ w: number; h: number } | null>(null); + + const onDragStart = useCallback((e: React.PointerEvent) => { + e.stopPropagation(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + dragRef.current = { startX: e.clientX, startY: e.clientY, origX: panel.x, origY: panel.y }; + setLocalPos({ x: panel.x, y: panel.y }); + }, [panel.x, panel.y]); + + const onDragMove = useCallback((e: React.PointerEvent) => { + if (!dragRef.current) return; + const dx = e.clientX - dragRef.current.startX; + const dy = e.clientY - dragRef.current.startY; + const nx = dragRef.current.origX + dx; + const ny = dragRef.current.origY + dy; + setLocalPos({ x: nx, y: ny }); + // Push the live position into Redux so the dashboard tether stays + // glued to the panel during the drag instead of lagging until pointer + // up. setLocalPos is kept for sub-frame smoothness, but Redux is the + // tether's source of truth. + dispatch(setConfigurePanelPosition({ workflowId: panel.workflow_id, x: nx, y: ny })); + }, [dispatch, panel.workflow_id]); + + const onDragEnd = useCallback((e: React.PointerEvent) => { + if (!dragRef.current) return; + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + dragRef.current = null; + setLocalPos(null); + }, []); + + const onResizeStart = useCallback((e: React.PointerEvent) => { + e.stopPropagation(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: panel.width, origH: panel.height }; + setLocalSize({ w: panel.width, h: panel.height }); + }, [panel.width, panel.height]); + + const onResizeMove = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return; + const dw = e.clientX - resizeRef.current.startX; + const dh = e.clientY - resizeRef.current.startY; + setLocalSize({ + w: Math.max(MIN_W, resizeRef.current.origW + dw), + h: Math.max(MIN_H, resizeRef.current.origH + dh), + }); + }, []); + + const onResizeEnd = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return; + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + if (localSize) { + dispatch(setConfigurePanelSize({ workflowId: panel.workflow_id, width: localSize.w, height: localSize.h })); + } + resizeRef.current = null; + setLocalSize(null); + }, [dispatch, localSize, panel.workflow_id]); + + const displayX = localPos?.x ?? panel.x; + const displayY = localPos?.y ?? panel.y; + const displayW = localSize?.w ?? panel.width; + const displayH = localSize?.h ?? panel.height; + + return ( + + {/* Drag handle + close X strip across the top. Stays slim so the + full Action Library underneath gets the vertical space. */} + + + Action Library + dispatch(closeConfigurePanel(panel.workflow_id))} + onPointerDown={(e) => e.stopPropagation()} + sx={{ p: 0.25, color: c.text.muted, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}> + + + + {/* Body: the real Action Library, exact same component as /actions. */} + + + + {/* SE resize handle. */} + + + ); +} diff --git a/frontend/src/app/pages/Workflows/GeneralFacet.tsx b/frontend/src/app/pages/Workflows/GeneralFacet.tsx index 8a667f76..72a4e038 100644 --- a/frontend/src/app/pages/Workflows/GeneralFacet.tsx +++ b/frontend/src/app/pages/Workflows/GeneralFacet.tsx @@ -1,16 +1,60 @@ -import React, { useState } from 'react'; +import React from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import InputBase from '@mui/material/InputBase'; import Select from '@mui/material/Select'; import MenuItem from '@mui/material/MenuItem'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch } from '@/shared/hooks'; +import { fetchSession, resumeSession } from '@/shared/state/agentsSlice'; +import { + DEFAULT_CARD_H, + DEFAULT_CARD_W, + placeCard, +} from '@/shared/state/dashboardLayoutSlice'; +import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; +import { store } from '@/shared/state/store'; import type { Workflow } from '@/shared/state/workflowsSlice'; import { FieldRow, BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon'; export default function GeneralFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) { const c = useClaudeTokens(); - const [editingPrompt, setEditingPrompt] = useState(false); + const dispatch = useAppDispatch(); + const sourceSessionId = draft.source_session_id || null; + // Open the source chat: fetch if missing, fall through to resume if + // it was closed, place a card if there isn't one. That's it. No pan + // animation, no focus pin, no dashboard_id patching, no auto-clear + // timers. Match the way any other chat opens on the canvas; let the + // user scroll to it. + const openSourceChat = React.useCallback(async () => { + if (!sourceSessionId) return; + const sid = sourceSessionId; + if (!store.getState().agents.sessions[sid]) { + try { + await dispatch(fetchSession(sid)).unwrap(); + } catch { + try { + await dispatch(resumeSession({ sessionId: sid })).unwrap(); + } catch { + return; + } + } + } + if (!store.getState().dashboardLayout.cards[sid]) { + dispatch(placeCard({ + sessionId: sid, + x: 400, y: 200, + width: DEFAULT_CARD_W, + height: DEFAULT_CARD_H, + })); + } + // Pan the canvas to the chat card so the user can see it. Safe to + // do here because the active element is the Edit button, not a + // textarea: handleCardSelect's input-aware blur guard prevents the + // focus animation from killing typing focus in a separate flow. + dispatch(setPendingFocusAgentId(sid)); + }, [sourceSessionId, dispatch]); return ( @@ -30,21 +74,16 @@ export default function GeneralFacet({ draft, setDraft }: { draft: Workflow; set /> - - setEditingPrompt((v) => !v)}> - {editingPrompt ? 'Editing…' : 'Edit'} - - - + - {editingPrompt && !draft.use_synced_prompt && ( + {!draft.use_synced_prompt && ( )} - Workflow + + Workflow + {sourceSessionId && ( + + + Edit + + )} + {draft.steps.map((s, idx) => ( diff --git a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx index 779e8900..2bce61ff 100644 --- a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx @@ -14,8 +14,7 @@ import NotificationsIcon from '@mui/icons-material/NotificationsNoneRounded'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { fetchCloudSmsStatus, type Workflow, type ScheduleConfig, type PermissionTier } from '@/shared/state/workflowsSlice'; -import { WEEKDAY_LABEL, formatTime, fireTimesWithin } from './scheduleUtils'; -import { routingFor } from './workflowVisuals'; +import { WEEKDAY_LABEL, formatTime } from './scheduleUtils'; import { nextTierAfter } from './permissionsUtils'; import { BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon'; @@ -183,9 +182,8 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se }; return ( - - {/* Row 1: master On/Off. Explicit so users never wonder if a stray - click armed a schedule. */} + + {/* Master on/off. */} setSched({ enabled: e.target.checked })} /> @@ -193,169 +191,166 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se - {/* Row 2: app-open status badge. Only render when the schedule is - actually on; an "OpenSwarm must be open at 9am" warning is - meaningless when nothing's scheduled. */} {s.enabled && ( )} - {/* Row 3: repeat + timezone. Icon replaces the "When should this - workflow run?" prose; the inputs read self-evidently. */} - - - - - setSched({ repeat_every: Math.max(1, Number(e.target.value) || 1) })} - sx={{ width: 48, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }} - /> - - - {s.repeat_unit === 'week' && ( - - {WEEKDAY_LABEL.map((label, idx) => { - const active = s.on_days.includes(idx); - return ( - setSched({ on_days: active ? s.on_days.filter((d) => d !== idx) : [...s.on_days, idx] })} - role="button" - sx={{ width: 26, height: 26, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: LABEL_FS, fontWeight: 700, cursor: 'pointer', color: active ? '#fff' : c.text.muted, bgcolor: active ? c.accent.primary : 'transparent', border: `1px solid ${active ? c.accent.primary : c.border.subtle}` }}>{label} - ); - })} - - )} - - {/* 12-hour picker; backend stores 0..23 but the UI uses 1..12+AM/PM - so users can't accidentally schedule "3" thinking it's 3pm and - get a 3am run. */} - - : - - - {friendlyTzLabel(s.timezone)} - - - {nextPreview && s.enabled && ( - - Next run: {formatNextRun(nextPreview)} + {/* Section: When should this workflow run? */} + + + When should this workflow run? - )} - - {/* Row 4: end condition. */} - - - - - - {endKind === 'on_date' && ( + + Repeat every { - const v = e.target.value; - setSched({ ends_at: v ? new Date(v + 'T23:59:59').toISOString() : null }); - }} - sx={{ fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }} + type="number" + value={s.repeat_every} + onChange={(e) => setSched({ repeat_every: Math.max(1, Number(e.target.value) || 1) })} + sx={{ width: 56, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }} /> - )} - {endKind === 'after_n' && ( - - setSched({ max_runs: Math.max(1, Number(e.target.value) || 1) })} - sx={{ width: 56, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }} - /> - runs ({s.runs_count} so far) + + + {s.repeat_unit === 'week' && ( + + ↳ on + {WEEKDAY_LABEL.map((label, idx) => { + const active = s.on_days.includes(idx); + return ( + setSched({ on_days: active ? s.on_days.filter((d) => d !== idx) : [...s.on_days, idx] })} + role="button" + sx={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: LABEL_FS, fontWeight: 700, cursor: 'pointer', color: active ? '#fff' : c.text.muted, bgcolor: active ? c.accent.primary : 'transparent', border: `1px solid ${active ? c.accent.primary : c.border.subtle}` }}>{label} + ); + })} )} - - {/* Inline warnings when the end condition is already satisfied; the - scheduler will auto-disable on the next tick which surprises - users who expected to arm a fresh schedule. */} - {(() => { - if (endKind === 'on_date' && s.ends_at) { - const ends = new Date(s.ends_at).getTime(); - if (!Number.isNaN(ends) && ends <= Date.now()) { + + At + + : + + + {friendlyTzLabel(s.timezone)} + + {nextPreview && s.enabled && ( + + Next run: {formatNextRun(nextPreview)} + + )} + + Runs + + {endKind === 'on_date' && ( + { + const v = e.target.value; + setSched({ ends_at: v ? new Date(v + 'T23:59:59').toISOString() : null }); + }} + sx={{ fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }} + /> + )} + {endKind === 'after_n' && ( + + setSched({ max_runs: Math.max(1, Number(e.target.value) || 1) })} + sx={{ width: 56, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }} + /> + runs ({s.runs_count} so far) + + )} + + {(() => { + if (endKind === 'on_date' && s.ends_at) { + const ends = new Date(s.ends_at).getTime(); + if (!Number.isNaN(ends) && ends <= Date.now()) { + return ( + + This date is in the past. The schedule will turn itself off. + + ); + } + } + if (endKind === 'after_n' && s.max_runs != null && s.runs_count >= s.max_runs) { return ( - - This date is in the past. The schedule will turn itself off. + + This workflow has already run {s.runs_count}× (limit {s.max_runs}). Raise the number or reset the counter to re-arm. ); } - } - if (endKind === 'after_n' && s.max_runs != null && s.runs_count >= s.max_runs) { - return ( - - This workflow has already run {s.runs_count}× (limit {s.max_runs}). Raise the number or reset the counter to re-arm. - - ); - } - return null; - })()} + return null; + })()} + + If missed + + + - {/* Row 5: cost. Pass the live draft schedule so the row stays in - sync with the "Next run" preview even before the user saves. */} - setDraft({ ...draft, cost_cap_usd_monthly: v })} /> - - {/* Row 6: action surface (freeze). */} - - - - + {/* Section: What can the agent do? */} + + + What can the agent do? + setSched({ on_missed: e.target.value as ScheduleConfig['on_missed'] })} - sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}> - Skip the missed run - Run once after I wake the app - + {/* Section: How should the agent ask for your permission? */} + + + How should the agent ask for your permission? + + {(draft.permissions || []).map((tier, idx) => ( + setTier(idx, patch)} + onRemove={idx === 0 ? undefined : () => removeTier(idx)} + /> + ))} + {canAddBackup && ( + + Escalate if I don't respond + )} - - {/* Row 8: permission tiers. */} - - - When the agent needs your OK - - {(draft.permissions || []).map((tier, idx) => ( - setTier(idx, patch)} - onRemove={idx === 0 ? undefined : () => removeTier(idx)} - /> - ))} - {canAddBackup && ( - + Escalate if I don't respond - )} ); } @@ -437,74 +415,6 @@ function AppOpenStatusBadge({ info, hour, minute, onFix }: { info: AppOpenInfo; ); } -function CostRow({ workflow, draftSched, onCapChange }: { workflow: Workflow; draftSched: ScheduleConfig; onCapChange: (v: number | null) => void }) { - const c = useClaudeTokens(); - const est = workflow.cost_estimate; - const connectionMode = useAppSelector((s) => (s as { settings?: { data?: { connection_mode?: string } } }).settings?.data?.connection_mode); - // Compute fires/30-days live from the draft so the row matches the - // "Next run" preview even before the user saves. Backend's cached - // estimate is the saved-state value and would lie after a draft edit. - const liveFires = useMemo(() => { - if (!draftSched.enabled) return 0; - const now = new Date(); - const end = new Date(now.getTime() + 30 * 86400000); - return fireTimesWithin({ schedule: draftSched } as Workflow, now, end, 200).length; - }, [draftSched]); - const route = routingFor(workflow.model, connectionMode); - const lastRun = est?.last_run_usd ?? 0; - const monthly = lastRun * liveFires; - const cap = workflow.cost_cap_usd_monthly; - - // Subscription-routed workflows have no per-call cost we can project, - // so swap the row from "$X.XX/mo" copy to a usage-estimate sentence - // that tells the truth: covered by the plan, here's how often it fires. - if (route.kind === 'subscription') { - return ( - - - {liveFires > 0 - ? `Will use about ${liveFires} run${liveFires === 1 ? '' : 's'} per month from your ${route.subLabel} plan. No per-run cost.` - : `Covered by your ${route.subLabel} plan. No upcoming runs yet.`} - - - A monthly cost cap doesn't apply here. Your plan handles the usage limits. - - - ); - } - - return ( - - - {liveFires > 0 && lastRun > 0 - ? `About $${monthly.toFixed(2)} per month at the last run's cost.` - : liveFires > 0 - ? `Will run ${liveFires} time${liveFires === 1 ? '' : 's'} in the next 30 days. Run once to project a monthly cost.` - : 'No upcoming runs.'} - - {liveFires > 0 && lastRun > 0 && ( - - {`$${lastRun.toFixed(4)} × ${liveFires} runs`} - - )} - - Monthly cap: - $ - onCapChange(e.target.value === '' ? null : Math.max(0, Number(e.target.value)))} - sx={{ width: 72, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.3 }} - /> - - - We'll skip runs once you hit this for the month. You'll see the skip in History. - - - ); -} - function PermissionRow({ idx, tier, cloudSmsEnabled, onChange, onRemove }: { idx: number; tier: PermissionTier; diff --git a/frontend/src/app/pages/Workflows/StepList.tsx b/frontend/src/app/pages/Workflows/StepList.tsx index 5a7a5a40..cb4ea8c7 100644 --- a/frontend/src/app/pages/Workflows/StepList.tsx +++ b/frontend/src/app/pages/Workflows/StepList.tsx @@ -5,6 +5,7 @@ import React from 'react'; import Box from '@mui/material/Box'; +import TextareaAutosize from '@mui/material/TextareaAutosize'; import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; @@ -24,6 +25,10 @@ interface Props { framed?: boolean; // Callback when a step row is edited inline; only useful in Preview. onChangeStep?: (idx: number, text: string) => void; + // Callback when the trash icon next to a step is clicked. Pairs with + // onAddStep on the parent. Provide both when editing; omit for read-only. + onDeleteStep?: (idx: number) => void; + onAddStep?: () => void; } const CIRCLE_SIZE = 24; @@ -89,12 +94,11 @@ export default function StepList({ workflow, steps, runs, activeRunId, framed, o // no run is in flight, nothing is "active" so all discs stay // outlined, including step 1. const firstStep = idx === 0; - const frameThis = framed && firstStep; - // Target image #54: in framed mode, step 1's disc is a solid - // accent fill with white text (it's the "entry point"), steps - // 2+ are quiet outlined discs. During a live run the activeStepIdx - // takes over and overrides this baseline. - const primary = frameThis || isActive; + // All steps look identical when framed; the orange disc on + // step 1 already does the "entry point" signaling. Singling + // out step 1 made 2+ read as static text. + const frameThis = framed; + const primary = (framed && firstStep) || isActive; return ( {Icon ? : (idx + 1)} - + {onChangeStep ? ( - ) => onChangeStep(idx, e.target.value)} - sx={{ - width: '100%', resize: 'vertical', - fontFamily: 'inherit', fontSize: '0.92rem', color: c.text.primary, - border: frameThis ? `1px solid ${c.border.medium}` : `1px solid transparent`, + onChange={(e) => onChangeStep(idx, e.target.value)} + minRows={1} + style={{ + width: '100%', + resize: 'none', + boxSizing: 'border-box', + fontFamily: 'inherit', + fontSize: '0.92rem', + color: c.text.primary, + border: frameThis ? `1px solid ${c.border.medium}` : '1px solid transparent', borderRadius: `${c.radius.md}px`, - bgcolor: frameThis ? c.bg.surface : 'transparent', - px: frameThis ? 1.25 : 0, py: frameThis ? 0.75 : 0.1, lineHeight: 1.45, - '&:focus': { outline: 'none', borderColor: c.accent.primary }, + background: frameThis ? c.bg.surface : 'transparent', + padding: '6px 10px', + lineHeight: 1.45, + outline: 'none', + overflow: 'hidden', + transition: 'border-color 0.12s ease, background 0.12s ease', }} /> ) : ( diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index 8d0866b2..39535211 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -34,7 +34,6 @@ import { } from '@/shared/state/dashboardLayoutSlice'; import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import { fetchSession } from '@/shared/state/agentsSlice'; -import { AnimatePresence, motion } from 'framer-motion'; import WorkflowEditViews from './WorkflowEditViews'; import { HistoryDetail, HistoryList, PreviewView, SavedView } from './WorkflowCardSubviews'; import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun } from './workflowVisuals'; @@ -100,6 +99,7 @@ const WorkflowCard: React.FC = ({ const workflow = useAppSelector((s) => s.workflows.items[workflowId]); const runs = useAppSelector((s) => s.workflows.runs[workflowId]); const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); + // Transient "Starting…" label state on the Run button. See onClick handler // for the full rationale (avoid no-feedback flicker on fast manual runs). const [runStarting, setRunStarting] = useState(false); @@ -465,6 +465,18 @@ const WorkflowCard: React.FC = ({ action group rather than dropping Schedule onto a second line. Run is the only accent-colored button (it's the verb users actually do) but its border weight matches the siblings. */} + {isDraft && ( + + } active={false} accent onClick={() => {}} /> + } active={false} onClick={() => {}} /> + } active={false} onClick={() => {}} /> + + + + Schedule this task + + + )} {!isDraft && workflow && ( = ({ /> {!workflow.schedule.enabled && ( - - } - active={false} - onClick={() => { - // One-click arming: flip the master toggle ON with a - // sensible default (daily 9am if there's nothing set - // yet), then jump to the editor so the user can tweak. - // Saves the extra "open editor → flip toggle → save" - // dance for the common case. - const sched = workflow.schedule; - const next = { - ...sched, - enabled: true, - // If the workflow has never had a schedule, day-1 9am - // is the friendliest default. If we already had one - // (re-enabling after a pause), keep the user's prior - // settings untouched. - repeat_unit: sched.repeat_unit || 'day', - repeat_every: sched.repeat_every || 1, - hour: sched.hour || 9, - minute: sched.minute || 0, - }; - dispatch(updateWorkflow({ - id: workflow.id, - patch: { schedule: next as any }, - ifMatch: workflow.updated_at || null, - })); - dispatch(updateWorkflowCard({ workflowId, patch: { view: 'edit', editFacet: 'Schedule' } })); - }} - /> + { + const sched = workflow.schedule; + const next = { + ...sched, + enabled: true, + repeat_unit: sched.repeat_unit || 'day', + repeat_every: sched.repeat_every || 1, + hour: sched.hour || 9, + minute: sched.minute || 0, + }; + dispatch(updateWorkflow({ + id: workflow.id, + patch: { schedule: next as any }, + ifMatch: workflow.updated_at || null, + })); + dispatch(updateWorkflowCard({ workflowId, patch: { view: 'edit', editFacet: 'Schedule' } })); + }} + onPointerDown={(e) => e.stopPropagation()} + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.4, + fontSize: '0.82rem', fontWeight: 500, + color: c.text.secondary, + cursor: 'pointer', + '&:hover': { color: c.accent.primary }, + }}> + + Schedule this task )} @@ -556,14 +566,13 @@ const WorkflowCard: React.FC = ({ read as a "jump". Outer box is the scrollable viewport; the animated child changes per `card.view`. */} - - + {/* No AnimatePresence wrapper here on purpose: framer-motion's + crossfade was racing user-input events and stealing focus + from the title/description/step InputBases on every parent + re-render (Redux dispatches from selection/zOrder/etc.). The + tab body just swaps directly; the user doesn't notice the + missing crossfade. */} + {card.view === 'preview' && ( = ({ onBack={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }))} /> )} - - + {/* ===== Resize handles ===== */} @@ -714,16 +722,19 @@ function TabBtn({ label, icon, active, accent, breathe, breatheTooltip, dot, dot whiteSpace: 'nowrap', color: accent ? c.accent.primary : c.text.secondary, bgcolor: accent ? c.accent.primary + '14' : 'transparent', - border: `1px solid ${accent ? c.accent.primary + '50' : c.border.medium}`, + // Only the Run (accent) tab carries a border; Edit/History sit as + // quiet text-with-icon affordances so the primary verb stands out. + border: accent ? `1px solid ${c.accent.primary}50` : '1px solid transparent', borderRadius: `${c.radius.md}px`, cursor: 'pointer', userSelect: 'none', - '&:hover': { bgcolor: accent ? c.accent.primary + '22' : c.bg.elevated, borderColor: accent ? c.accent.primary : c.text.muted }, - // Active just nudges the border + bg, doesn't repaint the whole - // button. Mirrors macOS segmented-control behavior. - ...(active && { + '&:hover': { bgcolor: accent ? c.accent.primary + '22' : c.bg.elevated, borderColor: accent ? c.accent.primary : 'transparent' }, + // Active state: nudge bg only when this is a non-accent tab so the + // user can still see "you're on this view". Run's accent styling + // already does that job; piling a darker bg on top reads as + // disabled. + ...(active && !accent && { color: c.text.primary, bgcolor: c.bg.elevated, - borderColor: c.border.medium, }), // Subtle "ready" breath when a stale workflow's Run button hasn't // been touched in over 24h. ~3% scale + glow swell, slow enough diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index c18b4b80..8fefe6cd 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -152,31 +152,27 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, }, [liveSteps]); return ( - // minHeight: 100% so the bottom-right Discard/Save cluster pins to - // the bottom of the card body, not just below the last step. Without - // this, mt:auto has nothing to push against and the buttons floated - // up next to step 1 (image #68 bug). + // PreviewView visually matches SavedView (target image #107): same + // Scheduled / Permissions prose, same framed step boxes. Title + + // description come from the AI gen at save time; the user doesn't + // type a description here. Discard/Save sits in the bottom-right. - onChangeDescription(e.target.value)} - sx={{ - fontSize: '0.92rem', color: c.text.secondary, lineHeight: 1.55, - border: `1px solid transparent`, borderRadius: `${c.radius.md}px`, - px: 0.5, py: 0.25, - '&:hover': { borderColor: c.border.subtle }, - '&.Mui-focused': { borderColor: c.border.medium }, - '& textarea::placeholder': { color: c.text.ghost, opacity: 1 }, - }} - /> + + + Scheduled: + Not scheduled + + + Permissions: + Notify me in Open Swarm + + + {description && ( + + {description} + + )} - {/* Bottom-right cluster: Discard then Save, both pill-shaped with - their respective trash + check glyphs. Matches target #58 / #63. - mt:auto = pinned to the bottom of the flex column regardless of - how little content lives above. */} @@ -222,36 +218,42 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo const connectionMode = useAppSelector((s) => (s as { settings?: { data?: { connection_mode?: string } } }).settings?.data?.connection_mode); void c; void connectionMode; - // Inline step-1 edit per target image #63: the first framed step is - // editable in place; once the user touches it the Discard/Save buttons - // surface at the bottom right. Saving issues a steps PATCH against - // the workflow. - const [localFirstStep, setLocalFirstStep] = useState(null); + // All steps editable inline. Each keystroke updates a local override + // map; Discard/Save surface as soon as any step diverges from the + // saved value. On Save we PATCH the full steps array, preserving ids. + const [localSteps, setLocalSteps] = useState>({}); const [savingFirst, setSavingFirst] = useState(false); - const firstStepDirty = localFirstStep != null && steps[0] && localFirstStep !== steps[0].text; - const editableSteps = firstStepDirty && steps[0] - ? [{ ...steps[0], text: localFirstStep! }, ...steps.slice(1)] - : steps; + const firstStepDirty = useMemo(() => { + for (const k of Object.keys(localSteps)) { + const idx = Number(k); + const saved = steps[idx]?.text ?? ''; + if (localSteps[idx] !== saved) return true; + } + return false; + }, [localSteps, steps]); + const editableSteps = useMemo(() => { + if (!firstStepDirty) return steps; + return steps.map((s, idx) => (idx in localSteps ? { ...s, text: localSteps[idx] } : s)); + }, [firstStepDirty, steps, localSteps]); const onChangeFirstStep = useCallback((idx: number, text: string) => { - if (idx !== 0) return; - setLocalFirstStep(text); + setLocalSteps((prev) => ({ ...prev, [idx]: text })); }, []); const onSaveFirstStep = useCallback(async () => { - if (!firstStepDirty || savingFirst || !steps[0]) return; + if (!firstStepDirty || savingFirst) return; setSavingFirst(true); try { - const nextSteps = [{ ...steps[0], text: localFirstStep! }, ...steps.slice(1)]; + const nextSteps = steps.map((s, idx) => (idx in localSteps ? { ...s, text: localSteps[idx] } : s)); await dispatch(updateWorkflow({ id: workflow.id, patch: { steps: nextSteps }, ifMatch: workflow.updated_at || null, })); - setLocalFirstStep(null); + setLocalSteps({}); } finally { setSavingFirst(false); } - }, [firstStepDirty, savingFirst, steps, localFirstStep, dispatch, workflow.id, workflow.updated_at]); - const onDiscardFirstStep = useCallback(() => setLocalFirstStep(null), []); + }, [firstStepDirty, savingFirst, steps, localSteps, dispatch, workflow.id, workflow.updated_at]); + const onDiscardFirstStep = useCallback(() => setLocalSteps({}), []); // Habit suggestion: 3+ manual runs in the last 7 days on a workflow // that isn't scheduled → quietly offer to schedule it. One click flips // the schedule on at the most common time. Auto-disappears once the diff --git a/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx b/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx index 3e49cf65..eb8851aa 100644 --- a/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx @@ -97,19 +97,25 @@ export default function WorkflowEditViews({ workflow, facet, onChangeFacet, onDi Discard + Save are the same pill-style buttons used at the bottom of SavedView; placing them here gives the user a single place to commit OR throw away whatever they just edited. */} - - Currently Editing - - - + {/* Match target image #111: left cluster (label + facet picker) + flush-left, action pills flush-right, generous breathing room + between. Gap inside each cluster stays tight so the two read + as two distinct groups, not five evenly-spaced chips. */} + + + Currently Editing + + + + - - - + + + diff --git a/frontend/src/shared/hooks/useKeyboardShortcuts.ts b/frontend/src/shared/hooks/useKeyboardShortcuts.ts index d6558383..941b092e 100644 --- a/frontend/src/shared/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/shared/hooks/useKeyboardShortcuts.ts @@ -10,20 +10,31 @@ export function useKeyboardShortcuts() { const handler = useCallback( (e: KeyboardEvent) => { - const target = e.target as HTMLElement; - const isInput = - target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.isContentEditable; + const target = e.target as HTMLElement | null; + const active = document.activeElement as HTMLElement | null; + // Double-guard: e.target AND document.activeElement. A bare-letter + // shortcut would otherwise fire if focus is on a wrapper Box and the + // child input never received it, kicking the user out mid-type. + const isInputLike = (el: HTMLElement | null) => + !!el && ( + el.tagName === 'INPUT' || + el.tagName === 'TEXTAREA' || + el.isContentEditable || + !!el.closest('input, textarea, [contenteditable="true"]') + ); + if (isInputLike(target) || isInputLike(active)) return; - if (isInput) return; - - if (e.key === 'd' && !e.metaKey && !e.ctrlKey) { + // Mod-gated shortcuts only. Bare letters were footguns: typing the + // letter "d" anywhere outside a tagged input field used to navigate + // home, which surprised users typing workflow titles/descriptions. + if (e.key.toLowerCase() === 'd' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { + e.preventDefault(); navigate('/'); return; } - if (e.key === 'A' && e.shiftKey && !e.metaKey && !e.ctrlKey) { + if (e.key === 'A' && e.shiftKey && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); for (const session of Object.values(sessions)) { for (const req of session.pending_approvals) { dispatch(handleApproval({ requestId: req.id, behavior: 'allow' })); @@ -32,7 +43,8 @@ export function useKeyboardShortcuts() { return; } - if (e.key === 'D' && e.shiftKey && !e.metaKey && !e.ctrlKey) { + if (e.key === 'D' && e.shiftKey && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); for (const session of Object.values(sessions)) { for (const req of session.pending_approvals) { dispatch(handleApproval({ requestId: req.id, behavior: 'deny' })); @@ -41,7 +53,8 @@ export function useKeyboardShortcuts() { return; } - if (e.key >= '1' && e.key <= '9' && !e.metaKey && !e.ctrlKey) { + if (e.key >= '1' && e.key <= '9' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { + e.preventDefault(); const idx = parseInt(e.key) - 1; const sessionList = Object.values(sessions).sort( (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 0c224831..37c6a5bc 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -101,11 +101,20 @@ export interface NotePosition { export const DEFAULT_NOTE_W = 240; export const DEFAULT_NOTE_H = 200; +export interface ConfigurePanelPosition { + workflow_id: string; + x: number; + y: number; + width: number; + height: number; +} + export interface DashboardLayoutState { cards: Record; viewCards: Record; browserCards: Record; workflowCards: Record; + configurePanels: Record; workflowsHub: WorkflowsHubPosition | null; notes: Record; closedCardPositions: Record; @@ -128,6 +137,7 @@ const initialState: DashboardLayoutState = { viewCards: {}, browserCards: {}, workflowCards: {}, + configurePanels: {}, workflowsHub: null, notes: {}, closedCardPositions: {}, @@ -148,6 +158,7 @@ interface LayoutPayload { viewCards: Record; browserCards: Record; workflowCards: Record; + configurePanels: Record; workflowsHub: WorkflowsHubPosition | null; notes: Record; expandedSessionIds: string[]; @@ -182,6 +193,7 @@ export const fetchLayout = createAsyncThunk( viewCards: (layout.view_cards ?? {}) as Record, browserCards: browserCards as Record, workflowCards: (layout.workflow_cards ?? {}) as Record, + configurePanels: (layout.configure_panels ?? {}) as Record, workflowsHub: (layout.workflows_hub ?? null) as WorkflowsHubPosition | null, notes: (layout.notes ?? {}) as Record, expandedSessionIds: (layout.expanded_session_ids ?? []) as string[], @@ -205,6 +217,7 @@ export const saveLayout = createAsyncThunk( view_cards: payload.viewCards, browser_cards: payload.browserCards, workflow_cards: payload.workflowCards, + configure_panels: payload.configurePanels, workflows_hub: payload.workflowsHub, notes: payload.notes, expanded_session_ids: payload.expandedSessionIds, @@ -386,6 +399,30 @@ const dashboardLayoutSlice = createSlice({ action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub' }>, ) { const { id, type } = action.payload; + // Compute the current top zOrder across ALL card types so we can + // short-circuit when the target is already on top. Without this + // guard, every click on a card (which fires onPointerDownCapture + + // onClick + onDoubleClick) bumps zOrder and triggers a Redux + // mutation. That mutation cascades into a re-render that unmounts + // inputs mid-keystroke, causing the workflow card's title / + // description / step textareas to lose focus on every click. + let maxZ = 0; + let currentZ = 0; + const tally = (z: number | undefined) => { if (typeof z === 'number' && z > maxZ) maxZ = z; }; + for (const c of Object.values(state.cards)) tally(c.zOrder); + for (const c of Object.values(state.viewCards)) tally(c.zOrder); + for (const c of Object.values(state.browserCards)) tally(c.zOrder); + for (const c of Object.values(state.workflowCards)) tally(c.zOrder); + for (const n of Object.values(state.notes)) tally(n.zOrder); + if (state.workflowsHub) tally(state.workflowsHub.zOrder); + if (type === 'agent') currentZ = state.cards[id]?.zOrder ?? 0; + else if (type === 'view') currentZ = state.viewCards[id]?.zOrder ?? 0; + else if (type === 'note') currentZ = state.notes[id]?.zOrder ?? 0; + else if (type === 'workflow') currentZ = state.workflowCards[id]?.zOrder ?? 0; + else if (type === 'workflows-hub') currentZ = state.workflowsHub?.zOrder ?? 0; + else currentZ = state.browserCards[id]?.zOrder ?? 0; + if (currentZ >= maxZ) return; // Already on top: no-op. + const z = state.nextZOrder++; if (type === 'agent') { const card = state.cards[id]; @@ -696,9 +733,65 @@ const dashboardLayoutSlice = createSlice({ if (!card) return; delete state.workflowCards[oldId]; state.workflowCards[newId] = { ...card, workflow_id: newId }; + // Carry any open Action-Library panel along with the rekey so the + // popout doesn't disappear when a draft is saved. + const panel = state.configurePanels[oldId]; + if (panel) { + delete state.configurePanels[oldId]; + state.configurePanels[newId] = { ...panel, workflow_id: newId }; + } if (state.pendingFocusWorkflowId === oldId) state.pendingFocusWorkflowId = newId; }, + openConfigurePanel( + state, + action: PayloadAction<{ workflowId: string }>, + ) { + const { workflowId } = action.payload; + // Anchor the panel just to the right of the workflow card. + const wfCard = state.workflowCards[workflowId]; + const baseX = wfCard ? wfCard.x + wfCard.width + GRID_GAP * 6 : 600; + const baseY = wfCard ? wfCard.y : 200; + const existing = state.configurePanels[workflowId]; + if (existing) { + existing.x = baseX; + existing.y = baseY; + return; + } + state.configurePanels[workflowId] = { + workflow_id: workflowId, + x: baseX, + y: baseY, + width: 580, + height: 600, + }; + }, + + setConfigurePanelPosition( + state, + action: PayloadAction<{ workflowId: string; x: number; y: number }>, + ) { + const { workflowId, x, y } = action.payload; + const p = state.configurePanels[workflowId]; + if (p) { p.x = x; p.y = y; } + }, + + setConfigurePanelSize( + state, + action: PayloadAction<{ workflowId: string; width: number; height: number }>, + ) { + const { workflowId, width, height } = action.payload; + const p = state.configurePanels[workflowId]; + if (p) { + p.width = Math.max(360, width); + p.height = Math.max(280, height); + } + }, + + closeConfigurePanel(state, action: PayloadAction) { + delete state.configurePanels[action.payload]; + }, + clearPendingFocusWorkflowId(state) { state.pendingFocusWorkflowId = null; }, @@ -1048,6 +1141,7 @@ const dashboardLayoutSlice = createSlice({ state.viewCards = {}; state.browserCards = {}; state.workflowCards = {}; + state.configurePanels = {}; state.workflowsHub = null; state.notes = {}; state.closedCardPositions = {}; @@ -1073,6 +1167,7 @@ const dashboardLayoutSlice = createSlice({ state.viewCards = action.payload.viewCards; state.browserCards = action.payload.browserCards; state.workflowCards = action.payload.workflowCards || {}; + state.configurePanels = action.payload.configurePanels || {}; state.workflowsHub = action.payload.workflowsHub || null; state.notes = action.payload.notes || {}; state.persistedExpandedSessionIds = action.payload.expandedSessionIds; @@ -1116,6 +1211,7 @@ const dashboardLayoutSlice = createSlice({ .addCase(deleteWorkflowFulfilledAction, (state, action) => { const id = action.payload; if (id && state.workflowCards[id]) delete state.workflowCards[id]; + if (id && state.configurePanels[id]) delete state.configurePanels[id]; }) .addCase(launchAndSendFirstMessage.fulfilled, (state, action) => { const { draftId, session } = action.payload; @@ -1169,6 +1265,10 @@ export const { setWorkflowCardSize, removeWorkflowCard, rekeyWorkflowCard, + openConfigurePanel, + closeConfigurePanel, + setConfigurePanelPosition, + setConfigurePanelSize, clearPendingFocusWorkflowId, openWorkflowsHub, closeWorkflowsHub,