[eric] workflow card: fix can't-type-in-edit-view + edit button + ai descriptions

This commit is contained in:
ciregenz
2026-05-19 01:40:33 -07:00
parent 20a3ae8506
commit e19846fc5f
13 changed files with 911 additions and 483 deletions
+10
View File
@@ -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)
+89 -26
View File
@@ -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:
+92 -7
View File
@@ -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<DashboardProps> = ({ 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<DashboardProps> = ({ 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<DashboardProps> = ({ 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<DashboardProps> = ({ 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<DashboardProps> = ({ 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<DashboardProps> = ({ 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<DashboardProps> = ({ 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<DashboardProps> = ({ dashboardId, isActive = true
onBringToFront={handleBringToFront}
/>
))}
{Object.values(configurePanels).map((p) => (
<ConfigurePanelCard
key={`configure-${p.workflow_id}`}
panel={p}
zOrder={1}
/>
))}
{Object.values(notes).map((n) => (
<NoteCard
key={`note-${n.note_id}`}
@@ -1,27 +1,34 @@
import React, { useState } from 'react';
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import Switch from '@mui/material/Switch';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { openConfigurePanel, closeConfigurePanel } from '@/shared/state/dashboardLayoutSlice';
import type { Workflow } from '@/shared/state/workflowsSlice';
import { BODY_FS, LABEL_FS, HINT_FS } from './workflowEditCommon';
const BUILT_IN_SETS = ['Core Actions', 'Extended Actions', 'Apps', 'Browser'] as const;
const CUSTOM_SETS = ['Notion', 'Google Workspace', 'YouTube', 'Reddit'] as const;
import { BODY_FS, LABEL_FS } from './workflowEditCommon';
export default function ActionsFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => 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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, color: c.text.secondary }}>
@@ -53,39 +60,21 @@ export default function ActionsFacet({ draft, setDraft }: { draft: Workflow; set
</Select>
</Box>
{/* 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 && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 0.5 }}>
<Box
onClick={() => 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'}
</Box>
</Box>
)}
{draft.actions.freeze && configuring && (
<Box sx={{ mt: 0.5, display: 'flex', flexDirection: 'column', gap: 0.6, border: `1px solid ${c.accent.primary}40`, borderRadius: `${c.radius.lg}px`, p: 1.25 }}>
<Typography sx={{ fontSize: HINT_FS, fontWeight: 700, color: c.text.secondary, letterSpacing: '0.05em', mb: 0.25 }}>BUILT-IN ACTION SETS</Typography>
{BUILT_IN_SETS.map((set) => (
<ActionSetRow key={set} set={set} enabled={draft.actions.configured_sets.includes(set)} onChange={(on) => toggleSet(set, on)} />
))}
<Typography sx={{ fontSize: HINT_FS, fontWeight: 700, color: c.text.secondary, letterSpacing: '0.05em', mt: 0.75, mb: 0.25 }}>CUSTOM ACTION SETS</Typography>
{CUSTOM_SETS.map((set) => (
<ActionSetRow key={set} set={set} enabled={draft.actions.configured_sets.includes(set)} onChange={(on) => toggleSet(set, on)} />
))}
</Box>
)}
</Box>
);
}
function ActionSetRow({ set, enabled, onChange }: { set: string; enabled: boolean; onChange: (v: boolean) => void }) {
const c = useClaudeTokens();
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.6 }}>
<Typography sx={{ flex: 1, fontSize: BODY_FS, color: c.text.primary, fontWeight: 600 }}>{set}</Typography>
<Switch size="small" checked={enabled} onChange={(e) => onChange(e.target.checked)} />
</Box>
);
}
@@ -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 (
<Box
data-select-type="configure-panel"
data-select-id={panel.workflow_id}
sx={{
position: 'absolute',
left: displayX,
top: displayY,
width: displayW,
height: displayH,
bgcolor: c.bg.surface,
border: `1px solid ${c.accent.primary}80`,
borderRadius: `${c.radius.lg}px`,
boxShadow: c.shadow.lg,
zIndex: zOrder,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Drag handle + close X strip across the top. Stays slim so the
full Action Library underneath gets the vertical space. */}
<Box
onPointerDown={onDragStart}
onPointerMove={onDragMove}
onPointerUp={onDragEnd}
onPointerCancel={onDragEnd}
sx={{
display: 'flex', alignItems: 'center', gap: 0.5,
px: 1, py: 0.5,
borderBottom: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.surface,
cursor: 'grab',
'&:active': { cursor: 'grabbing' },
flexShrink: 0,
userSelect: 'none',
}}>
<DragIndicatorIcon sx={{ fontSize: 14, color: c.text.muted }} />
<Box sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.secondary, flex: 1 }}>Action Library</Box>
<IconButton
size="small"
onClick={() => 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 } }}>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* Body: the real Action Library, exact same component as /actions. */}
<Box sx={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
<Tools />
</Box>
{/* SE resize handle. */}
<Box
onPointerDown={onResizeStart}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
sx={{
position: 'absolute',
right: 0, bottom: 0,
width: 14, height: 14,
cursor: 'nwse-resize',
opacity: 0.6,
'&:hover': { opacity: 1 },
// Diagonal stripes for the universal "drag-resize" hint.
background: `linear-gradient(135deg, transparent 50%, ${c.border.medium} 50%, ${c.border.medium} 60%, transparent 60%, transparent 75%, ${c.border.medium} 75%, ${c.border.medium} 85%, transparent 85%)`,
borderBottomRightRadius: `${EDGE}px`,
}}
/>
</Box>
);
}
@@ -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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<FieldRow label="Title">
@@ -30,21 +74,16 @@ export default function GeneralFacet({ draft, setDraft }: { draft: Workflow; set
/>
</FieldRow>
<FieldRow label="System prompt">
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ fontSize: LABEL_FS, color: c.accent.primary, cursor: 'pointer', fontWeight: 500 }} onClick={() => setEditingPrompt((v) => !v)}>
{editingPrompt ? 'Editing…' : 'Edit'}
</Box>
<Select
size="small"
value={draft.use_synced_prompt ? 'synced' : 'custom'}
onChange={(e) => setDraft({ ...draft, use_synced_prompt: e.target.value === 'synced' })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="synced">Synced to settings</MenuItem>
<MenuItem value="custom">Custom</MenuItem>
</Select>
</Box>
<Select
size="small"
value={draft.use_synced_prompt ? 'synced' : 'custom'}
onChange={(e) => setDraft({ ...draft, use_synced_prompt: e.target.value === 'synced' })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="synced">Synced to settings</MenuItem>
<MenuItem value="custom">Custom</MenuItem>
</Select>
</FieldRow>
{editingPrompt && !draft.use_synced_prompt && (
{!draft.use_synced_prompt && (
<InputBase
multiline
minRows={4}
@@ -54,7 +93,23 @@ export default function GeneralFacet({ draft, setDraft }: { draft: Workflow; set
sx={{ fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, p: 1, lineHeight: 1.5 }}
/>
)}
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>Workflow</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', mt: 0.5 }}>
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, flex: 1 }}>Workflow</Typography>
{sourceSessionId && (
<Box
role="button"
onClick={openSourceChat}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: LABEL_FS, fontWeight: 600,
color: c.text.muted, cursor: 'pointer',
'&:hover': { color: c.accent.primary },
}}>
<EditOutlinedIcon sx={{ fontSize: 14 }} />
Edit
</Box>
)}
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{draft.steps.map((s, idx) => (
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
+167 -257
View File
@@ -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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{/* Row 1: master On/Off. Explicit so users never wonder if a stray
click armed a schedule. */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Master on/off. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Switch size="small" checked={s.enabled} onChange={(e) => setSched({ enabled: e.target.checked })} />
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary }}>
@@ -193,169 +191,166 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
</Typography>
</Box>
{/* 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 && (
<AppOpenStatusBadge info={appOpen} hour={s.hour} minute={s.minute} onFix={fixAppOpen} />
)}
{/* Row 3: repeat + timezone. Icon replaces the "When should this
workflow run?" prose; the inputs read self-evidently. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap', mt: 0.5 }}>
<Tooltip title="How often this runs">
<RepeatIcon sx={{ fontSize: 16, color: c.text.muted }} />
</Tooltip>
<InputBase
type="number"
value={s.repeat_every}
onChange={(e) => 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 }}
/>
<Select
size="small"
value={s.repeat_unit}
onChange={(e) => setSched({ repeat_unit: e.target.value as ScheduleConfig['repeat_unit'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="day">day</MenuItem>
<MenuItem value="week">week</MenuItem>
<MenuItem value="month">month</MenuItem>
</Select>
</Box>
{s.repeat_unit === 'week' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pl: 2.5, flexWrap: 'wrap' }}>
{WEEKDAY_LABEL.map((label, idx) => {
const active = s.on_days.includes(idx);
return (
<Box
key={idx}
onClick={() => 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}</Box>
);
})}
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pl: 2.5 }}>
{/* 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. */}
<Select
size="small"
value={((s.hour + 11) % 12) + 1}
onChange={(e) => {
const h12 = Number(e.target.value);
const isPm = s.hour >= 12;
const next = (h12 % 12) + (isPm ? 12 : 0);
setSched({ hour: next });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
{Array.from({ length: 12 }, (_, i) => i + 1).map((h) => (
<MenuItem key={h} value={h}>{h}</MenuItem>
))}
</Select>
<Typography sx={{ fontSize: INPUT_FS, color: c.text.muted }}>:</Typography>
<Select
size="small"
value={s.minute}
onChange={(e) => setSched({ minute: Number(e.target.value) })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
{[0, 15, 30, 45].map((m) => (
<MenuItem key={m} value={m}>{String(m).padStart(2, '0')}</MenuItem>
))}
</Select>
<Select
size="small"
value={s.hour < 12 ? 'AM' : 'PM'}
onChange={(e) => {
const wasPm = s.hour >= 12;
const willBePm = e.target.value === 'PM';
if (wasPm === willBePm) return;
setSched({ hour: willBePm ? s.hour + 12 : s.hour - 12 });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="AM">AM</MenuItem>
<MenuItem value="PM">PM</MenuItem>
</Select>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost, ml: 1 }}>{friendlyTzLabel(s.timezone)}</Typography>
</Box>
{nextPreview && s.enabled && (
<Typography sx={{ fontSize: HINT_FS, color: c.accent.primary, pl: 2, fontWeight: 500 }}>
Next run: {formatNextRun(nextPreview)}
{/* Section: When should this workflow run? */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontSize: BODY_FS, fontWeight: 600, color: c.text.primary }}>
When should this workflow run?
</Typography>
)}
{/* Row 4: end condition. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
<Tooltip title="How long this should keep running">
<HourglassEmptyIcon sx={{ fontSize: 16, color: c.text.muted }} />
</Tooltip>
<Select
size="small"
value={endKind}
onChange={(e) => setEndKind(e.target.value as EndKind)}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="forever">Until I turn it off</MenuItem>
<MenuItem value="on_date">Until a date</MenuItem>
<MenuItem value="after_n">After a number of runs</MenuItem>
</Select>
{endKind === 'on_date' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>Repeat every</Typography>
<InputBase
type="date"
value={s.ends_at ? s.ends_at.slice(0, 10) : ''}
onChange={(e) => {
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' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<InputBase
type="number"
value={s.max_runs ?? 10}
onChange={(e) => 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 }}
/>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>runs ({s.runs_count} so far)</Typography>
<Select
size="small"
value={s.repeat_unit}
onChange={(e) => setSched({ repeat_unit: e.target.value as ScheduleConfig['repeat_unit'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="day">day</MenuItem>
<MenuItem value="week">week</MenuItem>
<MenuItem value="month">month</MenuItem>
</Select>
</Box>
{s.repeat_unit === 'week' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 12, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.muted }}> on</Typography>
{WEEKDAY_LABEL.map((label, idx) => {
const active = s.on_days.includes(idx);
return (
<Box
key={idx}
onClick={() => 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}</Box>
);
})}
</Box>
)}
</Box>
{/* 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()) {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>At</Typography>
<Select
size="small"
value={((s.hour + 11) % 12) + 1}
onChange={(e) => {
const h12 = Number(e.target.value);
const isPm = s.hour >= 12;
const next = (h12 % 12) + (isPm ? 12 : 0);
setSched({ hour: next });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
{Array.from({ length: 12 }, (_, i) => i + 1).map((h) => (
<MenuItem key={h} value={h}>{h}</MenuItem>
))}
</Select>
<Typography sx={{ fontSize: INPUT_FS, color: c.text.muted }}>:</Typography>
<Select
size="small"
value={s.minute}
onChange={(e) => setSched({ minute: Number(e.target.value) })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
{[0, 15, 30, 45].map((m) => (
<MenuItem key={m} value={m}>{String(m).padStart(2, '0')}</MenuItem>
))}
</Select>
<Select
size="small"
value={s.hour < 12 ? 'AM' : 'PM'}
onChange={(e) => {
const wasPm = s.hour >= 12;
const willBePm = e.target.value === 'PM';
if (wasPm === willBePm) return;
setSched({ hour: willBePm ? s.hour + 12 : s.hour - 12 });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="AM">AM</MenuItem>
<MenuItem value="PM">PM</MenuItem>
</Select>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost, ml: 0.5 }}>{friendlyTzLabel(s.timezone)}</Typography>
</Box>
{nextPreview && s.enabled && (
<Typography sx={{ fontSize: HINT_FS, color: c.accent.primary, pl: 12, fontWeight: 500 }}>
Next run: {formatNextRun(nextPreview)}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mt: 0.5 }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>Runs</Typography>
<Select
size="small"
value={endKind}
onChange={(e) => setEndKind(e.target.value as EndKind)}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="forever">Until I turn it off</MenuItem>
<MenuItem value="on_date">Until a date</MenuItem>
<MenuItem value="after_n">After a number of runs</MenuItem>
</Select>
{endKind === 'on_date' && (
<InputBase
type="date"
value={s.ends_at ? s.ends_at.slice(0, 10) : ''}
onChange={(e) => {
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' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<InputBase
type="number"
value={s.max_runs ?? 10}
onChange={(e) => 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 }}
/>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>runs ({s.runs_count} so far)</Typography>
</Box>
)}
</Box>
{(() => {
if (endKind === 'on_date' && s.ends_at) {
const ends = new Date(s.ends_at).getTime();
if (!Number.isNaN(ends) && ends <= Date.now()) {
return (
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 12 }}>
This date is in the past. The schedule will turn itself off.
</Typography>
);
}
}
if (endKind === 'after_n' && s.max_runs != null && s.runs_count >= s.max_runs) {
return (
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 2 }}>
This date is in the past. The schedule will turn itself off.
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 12 }}>
This workflow has already run {s.runs_count}× (limit {s.max_runs}). Raise the number or reset the counter to re-arm.
</Typography>
);
}
}
if (endKind === 'after_n' && s.max_runs != null && s.runs_count >= s.max_runs) {
return (
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 2 }}>
This workflow has already run {s.runs_count}× (limit {s.max_runs}). Raise the number or reset the counter to re-arm.
</Typography>
);
}
return null;
})()}
return null;
})()}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>If missed</Typography>
<Select
size="small"
value={s.on_missed === 'run_all' ? 'run_once' : s.on_missed}
onChange={(e) => setSched({ on_missed: e.target.value as ScheduleConfig['on_missed'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="skip">Skip the missed run</MenuItem>
<MenuItem value="run_once">Run once after I wake the app</MenuItem>
</Select>
</Box>
</Box>
{/* Row 5: cost. Pass the live draft schedule so the row stays in
sync with the "Next run" preview even before the user saves. */}
<CostRow workflow={draft} draftSched={s} onCapChange={(v) => setDraft({ ...draft, cost_cap_usd_monthly: v })} />
{/* Row 6: action surface (freeze). */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
<Tooltip title="What the agent is allowed to do while it runs">
<LockOutlinedIcon sx={{ fontSize: 16, color: c.text.muted }} />
</Tooltip>
{/* Section: What can the agent do? */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontSize: BODY_FS, fontWeight: 600, color: c.text.primary }}>
What can the agent do?
</Typography>
<Select
size="small"
value={draft.actions.freeze ? 'scoped' : 'full'}
@@ -373,42 +368,25 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
</Select>
</Box>
{/* Row 7: missed-run policy. Backend implements one catch-up only
today, so we don't expose a "run every missed time" option that
we couldn't honor. If the backend gains real replay support
later, add the third option back. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.25 }}>
<Tooltip title="What to do if your computer was asleep when a run was due">
<BedtimeIcon sx={{ fontSize: 16, color: c.text.muted }} />
</Tooltip>
<Select
size="small"
value={s.on_missed === 'run_all' ? 'run_once' : s.on_missed}
onChange={(e) => setSched({ on_missed: e.target.value as ScheduleConfig['on_missed'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="skip">Skip the missed run</MenuItem>
<MenuItem value="run_once">Run once after I wake the app</MenuItem>
</Select>
{/* Section: How should the agent ask for your permission? */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontSize: BODY_FS, fontWeight: 600, color: c.text.primary }}>
How should the agent ask for your permission?
</Typography>
{(draft.permissions || []).map((tier, idx) => (
<PermissionRow
key={idx}
idx={idx}
tier={tier}
cloudSmsEnabled={Boolean(cloudSms)}
onChange={(patch) => setTier(idx, patch)}
onRemove={idx === 0 ? undefined : () => removeTier(idx)}
/>
))}
{canAddBackup && (
<Box onClick={addBackup} role="button" sx={{ fontSize: LABEL_FS, color: c.text.muted, cursor: 'pointer', mt: 0.5, fontWeight: 500, '&:hover': { color: c.accent.primary } }}>+ Escalate if I don&apos;t respond</Box>
)}
</Box>
{/* Row 8: permission tiers. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<NotificationsIcon sx={{ fontSize: 16, color: c.text.muted }} />
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>When the agent needs your OK</Typography>
</Box>
{(draft.permissions || []).map((tier, idx) => (
<PermissionRow
key={idx}
idx={idx}
tier={tier}
cloudSmsEnabled={Boolean(cloudSms)}
onChange={(patch) => setTier(idx, patch)}
onRemove={idx === 0 ? undefined : () => removeTier(idx)}
/>
))}
{canAddBackup && (
<Box onClick={addBackup} role="button" sx={{ fontSize: LABEL_FS, color: c.text.muted, cursor: 'pointer', mt: 0.5, fontWeight: 500, '&:hover': { color: c.accent.primary } }}>+ Escalate if I don&apos;t respond</Box>
)}
</Box>
);
}
@@ -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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, pl: 2 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>
{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.`}
</Typography>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost }}>
A monthly cost cap doesn&apos;t apply here. Your plan handles the usage limits.
</Typography>
</Box>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, pl: 2 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>
{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.'}
</Typography>
{liveFires > 0 && lastRun > 0 && (
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost }}>
{`$${lastRun.toFixed(4)} × ${liveFires} runs`}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>Monthly cap:</Typography>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost }}>$</Typography>
<InputBase
type="number"
placeholder="none"
value={cap == null ? '' : cap}
onChange={(e) => 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 }}
/>
</Box>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost }}>
We&apos;ll skip runs once you hit this for the month. You&apos;ll see the skip in History.
</Typography>
</Box>
);
}
function PermissionRow({ idx, tier, cloudSmsEnabled, onChange, onRemove }: {
idx: number;
tier: PermissionTier;
+43 -17
View File
@@ -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 (
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25, position: 'relative' }}>
<Box sx={{
@@ -112,20 +116,42 @@ export default function StepList({ workflow, steps, runs, activeRunId, framed, o
}}>
{Icon ? <Icon sx={{ fontSize: 13 }} /> : (idx + 1)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
flex: 1,
minWidth: 0,
// Hover + focus give 2+ steps a visible edge so the user
// discovers they're editable. Step 1 already shows a
// permanent frame; this just makes the rest discoverable.
'& textarea:hover': {
borderColor: `${c.border.medium} !important`,
background: `${c.bg.surface} !important`,
},
'& textarea:focus': {
borderColor: `${c.accent.primary} !important`,
background: `${c.bg.surface} !important`,
},
}}>
{onChangeStep ? (
<Box
component="textarea"
<TextareaAutosize
value={s.text}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => 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',
}}
/>
) : (
@@ -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<Props> = ({
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<Props> = ({
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 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0, opacity: 0.45, pointerEvents: 'none' }}>
<TabBtn label="Run" icon={<PlayArrowIcon sx={{ fontSize: 16 }} />} active={false} accent onClick={() => {}} />
<TabBtn label="Edit" icon={<EditIcon sx={{ fontSize: 16 }} />} active={false} onClick={() => {}} />
<TabBtn label="History" icon={<HistoryIcon sx={{ fontSize: 16 }} />} active={false} onClick={() => {}} />
<Box sx={{ flex: 1 }} />
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.82rem', fontWeight: 500, color: c.text.secondary }}>
<ScheduleIcon sx={{ fontSize: 14 }} />
Schedule this task
</Box>
</Box>
)}
{!isDraft && workflow && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
<TabBtn
@@ -514,38 +526,36 @@ const WorkflowCard: React.FC<Props> = ({
/>
<Box sx={{ flex: 1 }} />
{!workflow.schedule.enabled && (
<Box>
<TabBtn
label="Schedule this task"
icon={<ScheduleIcon sx={{ fontSize: 16 }} />}
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' } }));
}}
/>
<Box
role="button"
data-no-drag
onClick={() => {
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 },
}}>
<ScheduleIcon sx={{ fontSize: 14 }} />
Schedule this task
</Box>
)}
</Box>
@@ -556,14 +566,13 @@ const WorkflowCard: React.FC<Props> = ({
read as a "jump". Outer box is the scrollable viewport; the
animated child changes per `card.view`. */}
<Box ref={bodyScrollRef} data-no-drag sx={{ flex: 1, p: 2, overflowY: 'auto', minHeight: 0, position: 'relative', overscrollBehavior: 'contain', display: 'flex', flexDirection: 'column' }}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={card.view}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -2 }}
transition={{ duration: 0.14, ease: 'easeOut' }}
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{/* 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. */}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{card.view === 'preview' && (
<PreviewView
workflowId={workflowId}
@@ -636,8 +645,7 @@ const WorkflowCard: React.FC<Props> = ({
onBack={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }))}
/>
)}
</motion.div>
</AnimatePresence>
</Box>
</Box>
{/* ===== 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
@@ -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.
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
<InputBase
multiline
minRows={1}
value={description}
placeholder="Describe what this workflow does."
onChange={(e) => 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 },
}}
/>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
<Typography sx={{ fontSize: '0.88rem', fontWeight: 700, color: c.text.primary }}>Scheduled:</Typography>
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}>Not scheduled</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
<Typography sx={{ fontSize: '0.88rem', fontWeight: 700, color: c.text.primary }}>Permissions:</Typography>
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}>Notify me in Open Swarm</Typography>
</Box>
</Box>
{description && (
<Typography sx={{ fontSize: '0.92rem', color: c.text.secondary, lineHeight: 1.55, mt: 0.5 }}>
{description}
</Typography>
)}
<StepList steps={liveSteps} framed onChangeStep={onChangeStep} />
{/* 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. */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1, mt: 'auto' }}>
<ActionBtn label="Discard" tone="danger" icon="trash" onClick={onDiscard} />
<ActionBtn label="Save" tone="success" icon="check" onClick={onSave} disabled={busy} />
@@ -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<string | null>(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<Record<number, string>>({});
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
@@ -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. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'nowrap', minWidth: 0 }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, fontWeight: 500, flexShrink: 0 }}>Currently Editing</Typography>
<Select
size="small"
value={facet}
onChange={(e) => onChangeFacet(e.target.value as Props['facet'])}
sx={{ fontSize: LABEL_FS, minWidth: 0, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="General">General</MenuItem>
<MenuItem value="Actions">Actions</MenuItem>
<MenuItem value="Schedule">Schedule</MenuItem>
</Select>
<Box sx={{ flex: 1, minWidth: 0 }} />
<Box sx={{ display: 'inline-flex', flexShrink: 0 }}>
{/* 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. */}
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap', minWidth: 0, py: 0.5 }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, fontWeight: 500 }}>Currently Editing</Typography>
<Select
size="small"
value={facet}
onChange={(e) => onChangeFacet(e.target.value as Props['facet'])}
sx={{ fontSize: LABEL_FS, minWidth: 110, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="General">General</MenuItem>
<MenuItem value="Actions">Actions</MenuItem>
<MenuItem value="Schedule">Schedule</MenuItem>
</Select>
</Box>
<Box sx={{ flex: 1, minWidth: 24 }} />
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<ActionBtn
label="Discard"
tone="danger"
@@ -117,15 +123,15 @@ export default function WorkflowEditViews({ workflow, facet, onChangeFacet, onDi
disabled={!dirty || busy}
onClick={onDiscard}
/>
</Box>
<Box sx={{ display: 'inline-flex', flexShrink: 0, minWidth: 80, justifyContent: 'center' }}>
<ActionBtn
label={busy ? 'Saving…' : 'Save'}
tone="success"
icon="check"
disabled={!dirty || busy || saveState === 'saved'}
onClick={onSave}
/>
<Box sx={{ display: 'inline-flex', minWidth: 80, justifyContent: 'center' }}>
<ActionBtn
label={busy ? 'Saving…' : 'Save'}
tone="success"
icon="check"
disabled={!dirty || busy || saveState === 'saved'}
onClick={onSave}
/>
</Box>
</Box>
</Box>
@@ -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()
@@ -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<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
configurePanels: Record<string, ConfigurePanelPosition>;
workflowsHub: WorkflowsHubPosition | null;
notes: Record<string, NotePosition>;
closedCardPositions: Record<string, CardPosition>;
@@ -128,6 +137,7 @@ const initialState: DashboardLayoutState = {
viewCards: {},
browserCards: {},
workflowCards: {},
configurePanels: {},
workflowsHub: null,
notes: {},
closedCardPositions: {},
@@ -148,6 +158,7 @@ interface LayoutPayload {
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
configurePanels: Record<string, ConfigurePanelPosition>;
workflowsHub: WorkflowsHubPosition | null;
notes: Record<string, NotePosition>;
expandedSessionIds: string[];
@@ -182,6 +193,7 @@ export const fetchLayout = createAsyncThunk(
viewCards: (layout.view_cards ?? {}) as Record<string, ViewCardPosition>,
browserCards: browserCards as Record<string, BrowserCardPosition>,
workflowCards: (layout.workflow_cards ?? {}) as Record<string, WorkflowCardPosition>,
configurePanels: (layout.configure_panels ?? {}) as Record<string, ConfigurePanelPosition>,
workflowsHub: (layout.workflows_hub ?? null) as WorkflowsHubPosition | null,
notes: (layout.notes ?? {}) as Record<string, NotePosition>,
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<string>) {
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,