mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
[eric] workflow card: fix can't-type-in-edit-view + edit button + ai descriptions
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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'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'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'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'll skip runs once you hit this for the month. You'll see the skip in History.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionRow({ idx, tier, cloudSmsEnabled, onChange, onRemove }: {
|
||||
idx: number;
|
||||
tier: PermissionTier;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user