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