mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-21 17:17:41 +02:00
[aidan] feat: scheduled tasks (#105)
* [eric] ci: gitleaks-ignore the known historical secrets so our branch stops failing on leaks it didnt add * [eric] workflows: restore scheduled-tasks on the workflow line (revert removal, keep windows fixes + 1.1.69) * [eric] workflows: re-apply uncommitted scheduling wip (schedule pill, calendar view, slice) * [eric] ops: gitignore dev-team local state files * [eric] ops: backlog item for download-tracking visibility * [eric] ci: allowlist the cdp-routes redaction-test token in gitleaks * [aidan] feat/scheduled-tasks: keep step labels in sync on edit and show chevron on every step * [aidan] fix: schedule time in chat * [aidan] ux/workflows: add workflow step removal (#91) * [aidan] feat/scheduled-tasks: remember workflow tool permissions across runs * [aidan] feat/task-scheduling: add hourly and minute (15-min minimum) schedule intervals (#93) * [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. * [aidan] feat/workflow-edit: add draft testing save flow * [aidan] ux/chat: remove continue chat button * [aidan] ux/workflows: polish workflow card interactions * [aidan] fix/workflow-scheduling: save unscheduled workflows as drafts * aidan ui: schedule naming changes * [aidan] ui: tool calling desc/naming * [aidan] ui: calendar sidebar naming * [aidan] ui: fix stop viewing closing chat * [aidan] feat/workflows: auto-name workflows and polish the build flow (#95) * [aidan] feat/workflow-auto-naming: auto-generate workflow titles from steps Generate a title + description from a workflow's steps (one aux call, reused for step labels) whenever it is still auto_named, so a workflow built in the Edit Agent names itself on commit instead of staying "New workflow". A manual rename sets auto_named=False and is never overwritten. Stream the aux call (non-streaming drops content on some 9router lanes) and fall back to a step-derived title when the model is unavailable. * [aidan] feat/workflows: hide unsaved new workflows until first save A brand-new "+ New" workflow is created with unsaved=true and kept out of the hub's scheduled/unscheduled lists while the user is still building it in the Edit Agent. The first commit (Save) clears the flag and the workflow appears. Every other create path stays visible immediately. * [aidan] ux/workflows: remove redundant save workflow button The Edit Agent already has Discard/Save controls in its strip, so the header "Save Workflow" button was a duplicate save path. Remove it and its pulse/edit-session-id wiring; the model/time subtitle stays. * [aidan] ux/workflows: animate title on auto-rename Wrap the workflow card title in the same Typewriter the chat card uses, so when the auto-generated name replaces the placeholder after Save it retypes letter-by-letter. Gated on a real (non-placeholder) title so it never animates on mount or for already-named workflows. * [aidan] ux/workflows: animate sidebar title on auto-rename Wrap the calendar hub's sidebar row title in the same Typewriter the workflow card uses, so a title that auto-renames retypes letter-by-letter in the sidebar too. Extract the placeholder/isRealTitle guard into the shared workflowVisuals so the card and sidebar stay in sync. * [aidan] fix/workflows: connect watch tether, keep watched chat open, wire draft run/history * [aidan] ui: grey out chat pill when not selected * [aidan] ui: fix running agent display * [aidan] feat/history-popover: add chat history and scheduled tasks run log tabs (#96) * [aidan] ux/schedule: toast when calendar view already open on expand * [aidan] ui: fix popover descs * [aidan] feat/workflow-runs: add pause, resume, and stop controls for live runs * [aidan] feat/schedule-calendar: add calendar occurrences endpoint and concrete timezones * [aidan] feat/workflows: require at least one step to save a workflow * [aidan] ux/edit-agent: hide Discard for an unsaved new workflow * [aidan] ux/edit-agent: move fix-prefix card below the step list * [aidan] feat/workflows: toast when an unattended scheduled run starts * [aidan] fix/dashboard-tethers: anchor workflow-sidecar tethers to measured card rects * [aidan] feat/workflows: validate steps before scheduling and keep chat tool memory * [aidan] feat/mcp-suggestions: dismissable integration banner with per-session cooldown * [aidan] feat/workflows: add scheduled-run "running now" toast with click-to-view (#97) * [aidan] ux/workflows: surface paused state on card, sidebar, and calendar; tidy run history * [aidan] feat/mcp-suggestions: suggest both Google and Microsoft when provider is ambiguous * [aidan] fix/agent-tokens: friendly out-of-tokens card across all agent surfaces * [aidan] feat/workflow-model: persist edit-agent model on save with switch notice and fresh drafts * [aidan] fix/workflow-chat: force stop on watched run mirrors workflow card stop * [aidan] fix/workflow-cards: keep watched run tethered on finish to avoid duplicate chat * [aidan] feat/schedule-calendar: mark current time with a now line in week view * [aidan] refactor/private-names: rename error and schedule classifiers from _ to p_ * [aidan] feat/scheduled-tasks: agent workflow scheduling and in-chat convert (#98) * [aidan] feat/workflow-suggest: nudge user to convert repeatable chat to workflow Add SuggestConvertToWorkflow MCP tool that agents call at the end of a task when they've completed something worth repeating (daily report, weekly check, recurring data pull). Frontend detects the tool call and glows the "Convert to workflow" button 3 times to draw the eye. When user clicks it, the suggested cadence (e.g. "every weekday at 9am") is stored in the draft and seeded into the scheduling agent's first prompt, so the agent can act on the suggestion rather than asking the user again. Tool is never auto-called — agents decide when a task is genuinely repeatable (not debugging, creative work, one-off lookup). Tool description emphasizes sparse, high-confidence use only (once per session max). Files changed: - backend/apps/agents/schedule_mcp_server.py: add SuggestConvertToWorkflow tool - frontend/src/shared/mcpToolMeta.ts: add label for new tool - frontend/src/app/pages/Dashboard/cards/AgentCard.tsx: detect suggestion in session messages, show+glow "Convert to workflow" button, pass cadence to draft - frontend/src/shared/state/workflowsSlice.ts: add suggested_cadence field to Workflow interface - frontend/src/app/pages/Workflows/SchedulingView.tsx: seed scheduling agent prompt with suggested cadence hint * [aidan] feat/agent-scheduling: route recurring asks through native workflows, deny claude cron skill * [aidan] feat/workflow-convert: in-chat convert popup and auto-open scheduled workflow card * [aidan] ux/calendar-page: schedule calendar restyle + popover fixes (#99) * [aidan] fix/dashboard-delete: remove workflows calendar panel on delete key * [aidan] ux/workflows-calendar: restyle hub, fix today highlight, add toolbar toggle * [aidan] ux/schedule-popover: compact density, fix sticky header bleed, add header spacing * [aidan] ux/schedule-calendar: hollow ring dot for past fires in month view * [aidan] ux/schedule-calendar: clickable +N more opens day's full run list * [aidan] feat/run-log-filters: add success and skipped pills to scheduled task history * [aidan] fix/convert-button: stop drag capture so convert-to-workflow click fires * [aidan] ux/calendar-card: match border color and radius to chat and workflow cards * [aidan] fix/minimap: render missed-runs card on the minimap * [aidan] ux/run-sparkline: simplify tooltip to plain run tally * [aidan] ux/run-history: collapse expanded run view to one clickable line * [aidan] ux/calendar-card: match corner radius to browser cards * [aidan] feat/workflows: launch-time scheduling UX and workflow-card polish (#101) * [aidan] feat/schedule-list: lazy-load list view via scroll sentinel * [aidan] feat/missed-runs: launch toast with per-workflow counts and pan-to-card * [aidan] fix/dashboard-tethers: keep watching line anchored on canvas zoom * [aidan] feat/scheduled-tasks: review missed runs at launch instead of auto-firing on_missed * [aidan] refactor/workflow-cards: use radius and status design tokens, polish card chrome * [aidan] ux/agent-card: keep convert-to-workflow visible during runs with mid-turn toast * [aidan] ux/mcp-bubble: drop redundant verb label when a workflow label is shown * [aidan] chore/backend: remove stale explanatory comments * [aidan] fix/workflows-hub: load workflows on hub mount so calendar fills at launch * [aidan] feat/workflows: generate title, description, step labels at convert time * [aidan] fix/tidy-layout: include workflows hub in tidy and fit-to-view * [aidan] feat/schedule-list: window long list via measured-height virtualizer * [aidan] ux/workflows-hub: remove time-saved badge from calendar header * [aidan] fix/types: add missing semantic-type labels and drop stray fade arg * [aidan] feat/schedule: pin monthly day-of-month and honor repeat-every intervals * [aidan] feat/schedule: inherit source-session tool surface for scheduled runs * [aidan] ux/calendar: restack hour-cell events as bars with overflow affordance * [aidan] feat/calendar: open the run card when clicking a scheduled occurrence * [aidan] ux/missed-runs: add per-group select-all toggle and rename skip action * [aidan] feat: new scheduled task design ported * [aidan] ui: sidebar reorder, repeat controls on schedule card * [aidan] ui: sidebar, scheduling time * [aidan] feat/schedule: pin monthly last-day-of-month * [aidan] feat/steps: per-step enable toggle * [aidan] feat/workflows: per-workflow color swatch * [aidan] feat/trash: soft-delete workflows with restore and purge * [aidan] feat/run-monitor: live run monitor card on the canvas * [aidan] feat/run-context: attach a run as removable chat context * [aidan] feat/compose: new-workflow landing page and auto-commit build flow * [aidan] ui/workflows: dark mode and design-system cohesion * [aidan] ui/calendar: overflow popover, condensed week view, scroll fix * [aidan] feat/home: ongoing runs, missed review, and accurate Coming-up counts * [aidan] fix/run-status: sync ongoing runs and heal stuck/interrupted runs * [aidan] ux/schedule: last-day-of-month UI, Run-at time typing, interval input * [aidan] ux/workflows: default window size and toolbar icon * [aidan] fix/schedule: measure ran_late from start and anchor recurrences to created_at * [aidan] feat/calendar: render fire times from backend, drop JS recurrence reimpl * [aidan] chore/dashboard: drop dead configure/missed-run cards, refetch on reconnect * [aidan] chore/agent-card: remove unreachable convert-to-workflow action * [aidan] fix/workflows: don't bump updated_at on a no-op draft commit so viewing a workflow doesn't reorder the sidebar * [aidan] feat/schedule: warn when scheduling a workflow that has no steps * [aidan] fix/selection-tool: never select the workflows app, and exit the tool on Escape without dropping selections * [aidan] ux/compose: diversify new-workflow starter prompts across personas * [aidan] ux/run-monitor: spawn the run card a bit farther right of the workflows app * [aidan] fix/schedule: harden run recovery and storage writes against crashes * [aidan] ux/compose: restyle new-workflow starters as a clean pill cluster with rich prompts * [aidan] fix/workflows: optimistically apply edits so the schedule banner updates instantly * [aidan] ui/workflows: three-tone surface depth so the window lifts off the canvas in both themes * [aidan] ui/workflows: close buttons turn red on hover, matching the chat card * [aidan] test/schedule: cover executor pipeline, storage durability, and recurrence gaps * [aidan] fix: remove package-lock json * [aidan] fix/workflows-compose: keep compose view until edit agent replies * [aidan] feat/workflows: auto-generate workflow + step titles with typewriter animation * [eric] deps: restore frontend/package-lock.json (PR #105 deletion broke npm ci) --------- Co-authored-by: Eric <ciregenz@berkeley.edu> Co-authored-by: cire <134991075+ciregenz@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
// Generic list windowing, lifted from AgentChat's transcript virtualizer so a
|
||||
// long schedule list mounts only the rows near the viewport (off-screen rows
|
||||
// unmount, replaced by measured-height spacers). Rows can be any height; the
|
||||
// hook measures them once on screen and estimates the rest. Top-anchored: the
|
||||
// list reads from the top, no bottom-following like the chat does.
|
||||
|
||||
// Keep this many screens of real content mounted on EACH side of the viewport.
|
||||
const BUFFER_SCREENS_PER_SIDE = 3;
|
||||
// Floor on mounted count so one very tall row can't strand an empty window.
|
||||
const MIN_BUFFER_ITEMS = 2;
|
||||
|
||||
// Pure solver: given scroll position and a per-index height accessor (measured
|
||||
// where known, estimated otherwise), return the [start, end) slice to mount.
|
||||
// Buffer is in PIXELS (N screens per side), so a few tall rows can't blow the
|
||||
// mounted set up to the whole list.
|
||||
export function computeDesiredWindow(
|
||||
scrollTop: number,
|
||||
clientHeight: number,
|
||||
total: number,
|
||||
heightOf: (index: number) => number,
|
||||
bufferPx: number,
|
||||
): { start: number; end: number } {
|
||||
if (total <= 0) return { start: 0, end: 0 };
|
||||
const keepTop = scrollTop - bufferPx;
|
||||
const keepBottom = scrollTop + clientHeight + bufferPx;
|
||||
let offset = 0;
|
||||
let start = -1;
|
||||
let end = total;
|
||||
for (let i = 0; i < total; i++) {
|
||||
const h = heightOf(i);
|
||||
const itemTop = offset;
|
||||
const itemBottom = offset + h;
|
||||
if (start === -1 && itemBottom > keepTop) start = i;
|
||||
if (itemTop < keepBottom) {
|
||||
end = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
offset += h;
|
||||
}
|
||||
if (start === -1) start = Math.max(0, total - 1);
|
||||
end = Math.min(total, Math.max(end, start + 1));
|
||||
if (end - start < MIN_BUFFER_ITEMS) {
|
||||
start = Math.max(0, Math.min(start, end - MIN_BUFFER_ITEMS));
|
||||
}
|
||||
return { start: Math.max(0, start), end };
|
||||
}
|
||||
|
||||
interface UseWindowedListArgs {
|
||||
// Stable id per row, in render order. Heights are cached by id so a measured
|
||||
// row keeps its height across re-renders even as the window slides.
|
||||
ids: string[];
|
||||
estimateHeight: (index: number) => number;
|
||||
// Off below this gates windowing entirely: render all, no spacers. Short
|
||||
// lists don't benefit and the spacer recompute just fights the scrollbar.
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface UseWindowedListResult {
|
||||
setScrollEl: (el: HTMLDivElement | null) => void;
|
||||
onScroll: () => void;
|
||||
start: number;
|
||||
end: number;
|
||||
topSpacer: number;
|
||||
bottomSpacer: number;
|
||||
}
|
||||
|
||||
export function useWindowedList({ ids, estimateHeight, enabled }: UseWindowedListArgs): UseWindowedListResult {
|
||||
const total = ids.length;
|
||||
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const heightsRef = useRef<Map<string, number>>(new Map());
|
||||
const [heightVersion, setHeightVersion] = useState(0);
|
||||
|
||||
const [start, setStart] = useState(0);
|
||||
const [end, setEnd] = useState(total);
|
||||
const startRef = useRef(0);
|
||||
const endRef = useRef(total);
|
||||
|
||||
const idsRef = useRef(ids);
|
||||
idsRef.current = ids;
|
||||
const estimateRef = useRef(estimateHeight);
|
||||
estimateRef.current = estimateHeight;
|
||||
|
||||
const heightOf = useCallback((index: number): number => {
|
||||
const id = idsRef.current[index];
|
||||
if (id == null) return 0;
|
||||
const measured = heightsRef.current.get(id);
|
||||
if (measured != null) return measured;
|
||||
return estimateRef.current(index);
|
||||
}, []);
|
||||
|
||||
const applyWindow = useCallback(() => {
|
||||
const el = scrollEl;
|
||||
if (!el || !enabled) return;
|
||||
const count = idsRef.current.length;
|
||||
const clientHeight = Math.max(1, el.clientHeight);
|
||||
const tightPx = BUFFER_SCREENS_PER_SIDE * clientHeight;
|
||||
const loosePx = tightPx + clientHeight;
|
||||
const tight = computeDesiredWindow(el.scrollTop, clientHeight, count, heightOf, tightPx);
|
||||
const loose = computeDesiredWindow(el.scrollTop, clientHeight, count, heightOf, loosePx);
|
||||
const curStart = startRef.current;
|
||||
const curEnd = endRef.current;
|
||||
// Hysteresis: must-mount the tight band, but keep already-mounted edges
|
||||
// until they drift past the looser band, so rows on the boundary don't
|
||||
// flip-flop mount/unmount on every scroll tick.
|
||||
let next = Math.max(loose.start, Math.min(curStart, tight.start));
|
||||
let nextEnd = Math.min(loose.end, Math.max(curEnd, tight.end));
|
||||
next = Math.max(0, Math.min(next, Math.max(0, nextEnd - 1)));
|
||||
if (next === curStart && nextEnd === curEnd) return;
|
||||
startRef.current = next;
|
||||
endRef.current = nextEnd;
|
||||
setStart(next);
|
||||
setEnd(nextEnd);
|
||||
}, [scrollEl, enabled, heightOf]);
|
||||
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const onScroll = useCallback(() => {
|
||||
if (rafRef.current != null) return;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
applyWindow();
|
||||
});
|
||||
}, [applyWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollEl) return;
|
||||
applyWindow();
|
||||
const obs = new ResizeObserver(() => applyWindow());
|
||||
obs.observe(scrollEl);
|
||||
return () => {
|
||||
obs.disconnect();
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [scrollEl, enabled, total, heightVersion, applyWindow]);
|
||||
|
||||
// Measure mounted rows after paint; a real height replaces its estimate and
|
||||
// nudges the window + spacers to the truth on the next frame.
|
||||
useLayoutEffect(() => {
|
||||
if (!scrollEl) return;
|
||||
let changed = false;
|
||||
scrollEl.querySelectorAll<HTMLElement>('[data-wl-id]').forEach((node) => {
|
||||
const id = node.dataset.wlId;
|
||||
if (!id) return;
|
||||
const h = node.offsetHeight;
|
||||
if (h <= 0) return;
|
||||
const prev = heightsRef.current.get(id);
|
||||
if (prev === undefined || Math.abs(prev - h) > 1) {
|
||||
heightsRef.current.set(id, h);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) setHeightVersion((v) => v + 1);
|
||||
});
|
||||
|
||||
const safeStart = enabled ? Math.min(Math.max(0, start), Math.max(0, total - 1)) : 0;
|
||||
const safeEnd = enabled ? Math.min(Math.max(safeStart + 1, end), total) : total;
|
||||
|
||||
const { topSpacer, bottomSpacer } = useMemo(() => {
|
||||
if (!enabled) return { topSpacer: 0, bottomSpacer: 0 };
|
||||
let top = 0;
|
||||
for (let i = 0; i < safeStart; i++) top += heightOf(i);
|
||||
let bottom = 0;
|
||||
for (let i = safeEnd; i < total; i++) bottom += heightOf(i);
|
||||
return { topSpacer: top, bottomSpacer: bottom };
|
||||
// heightVersion: spacers depend on the measured-height map (a ref the dep
|
||||
// checker can't see), recompute when a measurement lands.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, safeStart, safeEnd, total, heightVersion, heightOf]);
|
||||
|
||||
return { setScrollEl, onScroll, start: safeStart, end: safeEnd, topSpacer, bottomSpacer };
|
||||
}
|
||||
@@ -26,8 +26,150 @@ export function parseMcpToolName(rawName: string): McpToolInfo {
|
||||
return { isMcp: true, serverSlug, action, service, displayName: display };
|
||||
}
|
||||
|
||||
export function getMcpInputSummary(input: any): string {
|
||||
function formatTime(hour: unknown, minute: unknown): string {
|
||||
const h = typeof hour === 'number' ? hour : Number(hour);
|
||||
const m = typeof minute === 'number' ? minute : Number(minute || 0);
|
||||
if (!Number.isFinite(h)) return '';
|
||||
const h12 = ((h + 11) % 12) + 1;
|
||||
const suffix = h < 12 ? 'am' : 'pm';
|
||||
return Number.isFinite(m) && m > 0 ? `${h12}:${String(m).padStart(2, '0')}${suffix}` : `${h12}${suffix}`;
|
||||
}
|
||||
|
||||
function weekdayLabel(day: number): string {
|
||||
return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][day] || '';
|
||||
}
|
||||
|
||||
function compactWorkflowSchedule(input: any): string {
|
||||
if (!input || typeof input !== 'object') return '';
|
||||
const enabled = input.schedule_enabled ?? input.enabled;
|
||||
if (enabled === false) return 'Turn schedule off';
|
||||
|
||||
const unit = input.repeat_unit || input.unit;
|
||||
const every = Math.max(1, Number(input.repeat_every || 1));
|
||||
const time = formatTime(input.hour, input.minute);
|
||||
const days = Array.isArray(input.on_days) ? input.on_days.filter((d: any) => Number.isInteger(d) && d >= 0 && d <= 6) : [];
|
||||
|
||||
if (unit === 'minute') return `Run every ${Math.max(15, Number(input.repeat_every || 15))} minutes`;
|
||||
if (unit === 'hour') {
|
||||
const minute = Number(input.minute || 0);
|
||||
const suffix = minute > 0 ? ` at :${String(minute).padStart(2, '0')}` : '';
|
||||
return every === 1 ? `Run every hour${suffix}` : `Run every ${every} hours${suffix}`;
|
||||
}
|
||||
if (unit === 'day') return `Run ${every === 1 ? 'daily' : `every ${every} days`}${time ? ` at ${time}` : ''}`;
|
||||
if (unit === 'month') return `Run ${every === 1 ? 'monthly' : `every ${every} months`}${time ? ` at ${time}` : ''}`;
|
||||
if (unit === 'week') {
|
||||
const dayText = days.length === 1
|
||||
? weekdayLabel(days[0])
|
||||
: days.length === 5 && [1, 2, 3, 4, 5].every((d) => days.includes(d))
|
||||
? 'weekdays'
|
||||
: days.length > 1
|
||||
? days.map(weekdayLabel).filter(Boolean).join(', ')
|
||||
: '';
|
||||
if (!dayText) return time ? `Choose weekly days at ${time}` : 'Choose weekly days';
|
||||
return `Run ${every === 1 ? `every ${dayText}` : `every ${every} weeks on ${dayText}`}${time ? ` at ${time}` : ''}`;
|
||||
}
|
||||
if (input.title) return `Update "${input.title}"`;
|
||||
return '';
|
||||
}
|
||||
|
||||
function firstText(input: any, keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = input?.[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function stepNumber(input: any): string {
|
||||
const raw = input?.step_idx ?? input?.step_index ?? input?.index;
|
||||
const idx = typeof raw === 'number' ? raw : Number(raw);
|
||||
return Number.isInteger(idx) && idx >= 0 ? String(idx + 1) : '';
|
||||
}
|
||||
|
||||
function compactWorkflowAction(input: any, action: string): string {
|
||||
const lower = action.toLowerCase();
|
||||
const step = stepNumber(input);
|
||||
const text = firstText(input, ['new_text', 'text', 'prompt', 'description']);
|
||||
const label = firstText(input, ['new_label', 'label', 'title', 'name']);
|
||||
const preview = text || label;
|
||||
const shortPreview = preview.length > 80 ? preview.slice(0, 77) + '...' : preview;
|
||||
|
||||
if (lower === 'addworkflowstep') return shortPreview ? `Add step: ${shortPreview}` : 'Add a workflow step';
|
||||
if (lower === 'editworkflowstep') return step ? `Edit step ${step}` : 'Edit workflow step';
|
||||
if (lower === 'deleteworkflowstep') return step ? `Delete step ${step}` : 'Delete workflow step';
|
||||
if (lower === 'runworkflow') return 'Run this workflow now';
|
||||
if (lower === 'testworkflow') return 'Test this workflow';
|
||||
if (lower === 'readtesttranscript') return 'Read latest test results';
|
||||
if (lower === 'deletescheduledworkflow') return 'Delete this workflow';
|
||||
if (lower === 'pauseallworkflows') return 'Pause all scheduled workflows';
|
||||
if (lower === 'resumeallworkflows') return 'Resume scheduled workflows';
|
||||
if (lower === 'listworkflows') return 'Show workflows';
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getWorkflowToolLabel(action: string): string | null {
|
||||
const lower = action.toLowerCase();
|
||||
if (lower === 'scheduleworkflow') return 'Schedule workflow';
|
||||
if (lower === 'updatescheduledworkflow') return 'Update schedule';
|
||||
if (lower === 'deletescheduledworkflow') return 'Delete workflow';
|
||||
if (lower === 'pauseallworkflows') return 'Pause workflows';
|
||||
if (lower === 'resumeallworkflows') return 'Resume workflows';
|
||||
if (lower === 'runworkflow') return 'Run workflow';
|
||||
if (lower === 'editworkflowstep') return 'Edit workflow step';
|
||||
if (lower === 'addworkflowstep') return 'Add workflow step';
|
||||
if (lower === 'deleteworkflowstep') return 'Delete workflow step';
|
||||
if (lower === 'testworkflow') return 'Test workflow';
|
||||
if (lower === 'readtesttranscript') return 'Read test results';
|
||||
if (lower === 'listworkflows') return 'List workflows';
|
||||
if (lower === 'suggestconverttoworkflow') return 'Suggest workflow';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getWorkflowToolInputDisplay(input: any, action?: string, serverSlug?: string): string {
|
||||
if (!input || typeof input !== 'object') return '';
|
||||
const isWorkflowTool = serverSlug === 'openswarm-schedule' || (action && getWorkflowToolLabel(action));
|
||||
if (!isWorkflowTool || !action) return '';
|
||||
|
||||
const lower = action.toLowerCase();
|
||||
const lines: string[] = [];
|
||||
const schedule = compactWorkflowSchedule(input);
|
||||
const step = stepNumber(input);
|
||||
const text = firstText(input, ['new_text', 'text', 'prompt', 'description']);
|
||||
const label = firstText(input, ['new_label', 'label', 'title', 'name']);
|
||||
|
||||
if (lower === 'scheduleworkflow' || lower === 'updatescheduledworkflow') {
|
||||
if (schedule) lines.push(`Schedule: ${schedule}`);
|
||||
if (label) lines.push(`Name: ${label}`);
|
||||
return lines.join('\n') || getWorkflowToolLabel(action) || 'Workflow action';
|
||||
}
|
||||
|
||||
if (lower === 'addworkflowstep') {
|
||||
if (text) lines.push(`Step: ${text}`);
|
||||
if (label && label !== text) lines.push(`Label: ${label}`);
|
||||
return lines.join('\n') || compactWorkflowAction(input, action);
|
||||
}
|
||||
|
||||
if (lower === 'editworkflowstep') {
|
||||
if (step) lines.push(`Step: ${step}`);
|
||||
if (text) lines.push(`Prompt: ${text}`);
|
||||
if (label && label !== text) lines.push(`Label: ${label}`);
|
||||
return lines.join('\n') || compactWorkflowAction(input, action);
|
||||
}
|
||||
|
||||
return compactWorkflowAction(input, action) || getWorkflowToolLabel(action) || 'Workflow action';
|
||||
}
|
||||
|
||||
export function getMcpInputSummary(input: any, action?: string, serverSlug?: string): string {
|
||||
if (!input || typeof input !== 'object') return '';
|
||||
if (serverSlug === 'openswarm-schedule' || (action && getWorkflowToolLabel(action))) {
|
||||
const workflowSummary = compactWorkflowSchedule(input);
|
||||
if (workflowSummary) return workflowSummary;
|
||||
if (action) {
|
||||
const actionSummary = compactWorkflowAction(input, action);
|
||||
if (actionSummary) return actionSummary;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const keys = Object.keys(input);
|
||||
if (keys.length === 0) return '';
|
||||
if (keys.length === 1) {
|
||||
|
||||
@@ -68,6 +68,9 @@ export interface AgentSession {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'draft' | 'running' | 'waiting_approval' | 'completed' | 'error' | 'stopped';
|
||||
/** For workflow Test Agent sessions: drives the test card's footer
|
||||
* (running -> red Force Stop; complete/error -> green close). */
|
||||
workflow_test_state?: 'running' | 'complete' | 'error' | null;
|
||||
provider: string;
|
||||
model: string;
|
||||
mode: string;
|
||||
@@ -409,6 +412,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' }) => {
|
||||
@@ -713,6 +731,14 @@ const agentsSlice = createSlice({
|
||||
}
|
||||
},
|
||||
|
||||
setSessionTestState(
|
||||
state,
|
||||
action: PayloadAction<{ sessionId: string; state: 'running' | 'complete' | 'error' }>
|
||||
) {
|
||||
const session = state.sessions[action.payload.sessionId];
|
||||
if (session) session.workflow_test_state = action.payload.state;
|
||||
},
|
||||
|
||||
setSessionConnState(
|
||||
state,
|
||||
action: PayloadAction<{ sessionId: string; state: 'live' | 'reconnecting' }>
|
||||
@@ -1018,11 +1044,18 @@ const agentsSlice = createSlice({
|
||||
}
|
||||
},
|
||||
|
||||
closeSessionFromWs(state, action: PayloadAction<HistorySession>) {
|
||||
const entry = action.payload;
|
||||
closeSessionFromWs(state, action: PayloadAction<HistorySession & { keepSession?: boolean }>) {
|
||||
const { keepSession, ...entry } = action.payload;
|
||||
state.history[entry.id] = entry;
|
||||
|
||||
const session = state.sessions[entry.id];
|
||||
// keepSession: the user is watching this run live, so a workflow finishing
|
||||
// shouldn't yank the chat out from under them. Keep it as a normal
|
||||
// completed chat (continue / exit) instead of deleting the card.
|
||||
if (keepSession && session) {
|
||||
session.status = (entry.status as AgentSession['status']) || 'completed';
|
||||
return;
|
||||
}
|
||||
if (session?.mode === 'browser-agent' && session.parent_session_id) {
|
||||
session.status = (entry.status as AgentSession['status']) || 'completed';
|
||||
} else {
|
||||
@@ -1436,6 +1469,7 @@ export const {
|
||||
updateSession,
|
||||
updateSessionStatus,
|
||||
setSessionConnState,
|
||||
setSessionTestState,
|
||||
addMessage,
|
||||
addOptimisticMessage,
|
||||
markOptimisticFailed,
|
||||
|
||||
@@ -7,6 +7,9 @@ const fetchSessionRejectedAction = createAction<
|
||||
{ sessionId?: string; status?: number } | undefined
|
||||
>('agents/fetchSession/rejected');
|
||||
|
||||
// Cascade workflow delete to layout so the "Make workflow" tether stops pointing at empty space.
|
||||
const deleteWorkflowFulfilledAction = createAction<string>('workflows/delete/fulfilled');
|
||||
|
||||
const DASHBOARDS_API = `${API_BASE}/dashboards`;
|
||||
|
||||
export const DEFAULT_CARD_W = 480;
|
||||
@@ -15,12 +18,18 @@ export const DEFAULT_VIEW_CARD_W = 1280;
|
||||
export const DEFAULT_VIEW_CARD_H = 800;
|
||||
export const DEFAULT_BROWSER_CARD_W = 1280;
|
||||
export const DEFAULT_BROWSER_CARD_H = 800;
|
||||
export const DEFAULT_WORKFLOW_CARD_W = 480;
|
||||
export const DEFAULT_WORKFLOW_CARD_H = 520;
|
||||
// Open at the same default footprint as a browser/view card so it lands at a
|
||||
// comfortable size automatically.
|
||||
export const DEFAULT_WORKFLOWS_HUB_W = DEFAULT_BROWSER_CARD_W;
|
||||
export const DEFAULT_WORKFLOWS_HUB_H = DEFAULT_BROWSER_CARD_H;
|
||||
export const EXPANDED_CARD_MIN_H = 620;
|
||||
export const GRID_GAP = 24;
|
||||
const GRID_ORIGIN = { x: 40, y: 100 };
|
||||
const GRID_COLS_FALLBACK = 4;
|
||||
|
||||
export type CardType = 'agent' | 'view' | 'browser' | 'note';
|
||||
export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub' | 'workflows-monitor';
|
||||
|
||||
export interface CardPosition {
|
||||
session_id: string;
|
||||
@@ -65,6 +74,26 @@ export interface BrowserCardPosition {
|
||||
dashboard_id?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowCardPosition {
|
||||
workflow_id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zOrder: number;
|
||||
source_session_id?: string | null;
|
||||
}
|
||||
|
||||
/** Singleton per dashboard; only one Workflows Hub card open at a time. */
|
||||
export interface WorkflowsHubPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zOrder: number;
|
||||
}
|
||||
|
||||
|
||||
export type NoteColor = 'yellow' | 'pink' | 'blue' | 'green' | 'purple' | 'gray';
|
||||
|
||||
export interface NotePosition {
|
||||
@@ -85,6 +114,8 @@ export interface DashboardLayoutState {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
notes: Record<string, NotePosition>;
|
||||
closedCardPositions: Record<string, CardPosition>;
|
||||
glowingBrowserCards: Record<string, { sourceId: string; fading: boolean; label?: string }>;
|
||||
@@ -102,12 +133,35 @@ export interface DashboardLayoutState {
|
||||
endingBrowserCards: Record<string, { status: 'completed' | 'error'; at: number }>;
|
||||
/** Transient: id of the view card the user has clicked into; preload stops forwarding canvas gestures while set. */
|
||||
activeViewCardId: string | null;
|
||||
pendingFocusWorkflowId: string | null;
|
||||
/** Transient: signals Dashboard to pan/zoom to the singleton Workflows Hub on open. */
|
||||
pendingFocusWorkflowsHub: boolean;
|
||||
/** Transient deep-link target: the Workflows card jumps to this workflow's detail on open, then clears it. */
|
||||
workflowsAppTarget: string | null;
|
||||
/** Workflow id whose live run is being watched in the Run Monitor card docked beside the window. Null = closed. */
|
||||
workflowsMonitorId: string | null;
|
||||
/** Specific run id to show in the monitor (e.g. clicked from history); null = follow the latest run. */
|
||||
workflowsMonitorRunId: string | null;
|
||||
/** Geometry of the spawned Run Monitor card (a real canvas card, tethered to the window). Ephemeral, not persisted. */
|
||||
workflowsMonitorCard: WorkflowsHubPosition | null;
|
||||
/** A run attached to a workflow's chat as a removable context chip; its transcript rides along each send until removed. */
|
||||
workflowsRunContext: WorkflowsRunContext | null;
|
||||
}
|
||||
|
||||
export interface WorkflowsRunContext {
|
||||
workflowId: string;
|
||||
runId: string;
|
||||
title: string;
|
||||
metaLabel: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const initialState: DashboardLayoutState = {
|
||||
cards: {},
|
||||
viewCards: {},
|
||||
browserCards: {},
|
||||
workflowCards: {},
|
||||
workflowsHub: null,
|
||||
notes: {},
|
||||
closedCardPositions: {},
|
||||
glowingBrowserCards: {},
|
||||
@@ -121,12 +175,21 @@ const initialState: DashboardLayoutState = {
|
||||
suspendedBrowserCards: {},
|
||||
endingBrowserCards: {},
|
||||
activeViewCardId: null,
|
||||
pendingFocusWorkflowId: null,
|
||||
pendingFocusWorkflowsHub: false,
|
||||
workflowsAppTarget: null,
|
||||
workflowsMonitorId: null,
|
||||
workflowsMonitorRunId: null,
|
||||
workflowsMonitorCard: null,
|
||||
workflowsRunContext: null,
|
||||
};
|
||||
|
||||
interface LayoutPayload {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
notes: Record<string, NotePosition>;
|
||||
expandedSessionIds: string[];
|
||||
}
|
||||
@@ -163,6 +226,8 @@ export const fetchLayout = createAsyncThunk(
|
||||
cards: (layout.cards ?? {}) as Record<string, CardPosition>,
|
||||
viewCards: (layout.view_cards ?? {}) as Record<string, ViewCardPosition>,
|
||||
browserCards: browserCards as Record<string, BrowserCardPosition>,
|
||||
workflowCards: (layout.workflow_cards ?? {}) as Record<string, WorkflowCardPosition>,
|
||||
workflowsHub: (layout.workflows_hub ?? null) as WorkflowsHubPosition | null,
|
||||
notes: (layout.notes ?? {}) as Record<string, NotePosition>,
|
||||
expandedSessionIds: (layout.expanded_session_ids ?? []) as string[],
|
||||
} satisfies LayoutPayload;
|
||||
@@ -184,6 +249,8 @@ export const saveLayout = createAsyncThunk(
|
||||
cards: payload.cards,
|
||||
view_cards: payload.viewCards,
|
||||
browser_cards: payload.browserCards,
|
||||
workflow_cards: payload.workflowCards,
|
||||
workflows_hub: payload.workflowsHub,
|
||||
notes: payload.notes,
|
||||
expanded_session_ids: payload.expandedSessionIds,
|
||||
},
|
||||
@@ -229,6 +296,12 @@ function collectOccupiedRects(
|
||||
if (exclude?.type === 'browser' && exclude.id === c.browser_id) continue;
|
||||
rects.push({ x: c.x, y: c.y, w: c.width, h: c.height });
|
||||
}
|
||||
for (const w of Object.values(state.workflowCards)) {
|
||||
rects.push({ x: w.x, y: w.y, w: w.width, h: w.height });
|
||||
}
|
||||
if (state.workflowsHub) {
|
||||
rects.push({ x: state.workflowsHub.x, y: state.workflowsHub.y, w: state.workflowsHub.width, h: state.workflowsHub.height });
|
||||
}
|
||||
for (const n of Object.values(state.notes)) {
|
||||
if (exclude?.type === 'note' && exclude.id === n.note_id) continue;
|
||||
rects.push({ x: n.x, y: n.y, w: n.width, h: n.height });
|
||||
@@ -436,7 +509,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
|
||||
bringToFront(
|
||||
state,
|
||||
action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' }>,
|
||||
action: PayloadAction<{ id: string; type: CardType }>,
|
||||
) {
|
||||
const { id, type } = action.payload;
|
||||
// Compute the current top zOrder across ALL card types so we can
|
||||
@@ -444,17 +517,24 @@ const dashboardLayoutSlice = createSlice({
|
||||
// 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.
|
||||
// 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 (state.workflowsMonitorCard) tally(state.workflowsMonitorCard.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 if (type === 'workflows-monitor') currentZ = state.workflowsMonitorCard?.zOrder ?? 0;
|
||||
else currentZ = state.browserCards[id]?.zOrder ?? 0;
|
||||
if (currentZ >= maxZ) return; // Already on top: no-op.
|
||||
|
||||
@@ -468,6 +548,13 @@ const dashboardLayoutSlice = createSlice({
|
||||
} else if (type === 'note') {
|
||||
const note = state.notes[id];
|
||||
if (note) note.zOrder = z;
|
||||
} else if (type === 'workflow') {
|
||||
const card = state.workflowCards[id];
|
||||
if (card) card.zOrder = z;
|
||||
} else if (type === 'workflows-hub') {
|
||||
if (state.workflowsHub) state.workflowsHub.zOrder = z;
|
||||
} else if (type === 'workflows-monitor') {
|
||||
if (state.workflowsMonitorCard) state.workflowsMonitorCard.zOrder = z;
|
||||
} else {
|
||||
const card = state.browserCards[id];
|
||||
if (card) card.zOrder = z;
|
||||
@@ -523,13 +610,19 @@ const dashboardLayoutSlice = createSlice({
|
||||
const agentCards = Object.values(state.cards);
|
||||
const viewCards = Object.values(state.viewCards);
|
||||
const bCards = Object.values(state.browserCards);
|
||||
const total = agentCards.length + viewCards.length + bCards.length;
|
||||
const wCards = Object.values(state.workflowCards);
|
||||
const hub = state.workflowsHub;
|
||||
const mon = state.workflowsMonitorCard;
|
||||
const total = agentCards.length + viewCards.length + bCards.length + wCards.length + (hub ? 1 : 0) + (mon ? 1 : 0);
|
||||
if (total === 0) return;
|
||||
|
||||
const allItems = [
|
||||
...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
|
||||
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
|
||||
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
|
||||
...wCards.map((c) => ({ kind: 'workflow' as const, id: c.workflow_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
|
||||
...(hub ? [{ kind: 'workflows-hub' as const, id: 'workflows-hub', x: hub.x, y: hub.y, storedW: hub.width, storedH: hub.height }] : []),
|
||||
...(mon ? [{ kind: 'workflows-monitor' as const, id: 'workflows-monitor', x: mon.x, y: mon.y, storedW: mon.width, storedH: mon.height }] : []),
|
||||
];
|
||||
allItems.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
|
||||
@@ -554,6 +647,13 @@ const dashboardLayoutSlice = createSlice({
|
||||
} else if (item.kind === 'view') {
|
||||
const card = state.viewCards[item.id];
|
||||
if (card) { card.x = pos.x; card.y = pos.y; }
|
||||
} else if (item.kind === 'workflow') {
|
||||
const card = state.workflowCards[item.id];
|
||||
if (card) { card.x = pos.x; card.y = pos.y; }
|
||||
} else if (item.kind === 'workflows-hub') {
|
||||
if (state.workflowsHub) { state.workflowsHub.x = pos.x; state.workflowsHub.y = pos.y; }
|
||||
} else if (item.kind === 'workflows-monitor') {
|
||||
if (state.workflowsMonitorCard) { state.workflowsMonitorCard.x = pos.x; state.workflowsMonitorCard.y = pos.y; }
|
||||
} else {
|
||||
const card = state.browserCards[item.id];
|
||||
if (card) { card.x = pos.x; card.y = pos.y; }
|
||||
@@ -736,6 +836,207 @@ const dashboardLayoutSlice = createSlice({
|
||||
delete state.suspendedBrowserCards[action.payload];
|
||||
},
|
||||
|
||||
addWorkflowCard(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
workflowId: string;
|
||||
sourceSessionId?: string | null;
|
||||
expandedSessionIds?: string[];
|
||||
}>,
|
||||
) {
|
||||
const { workflowId, sourceSessionId, expandedSessionIds } = action.payload;
|
||||
if (state.workflowCards[workflowId]) {
|
||||
state.workflowCards[workflowId].zOrder = state.nextZOrder++;
|
||||
state.pendingFocusWorkflowId = workflowId;
|
||||
return;
|
||||
}
|
||||
// Fall back to persistedExpandedSessionIds when the caller didn't
|
||||
// wire the live list through. Without it, collectOccupiedRects sees
|
||||
// every chat at its stored (collapsed) height, and a workflow
|
||||
// spawned from an open chat lands on top of the visibly-tall card.
|
||||
const expanded = expandedSessionIds ?? state.persistedExpandedSessionIds;
|
||||
const rects = collectOccupiedRects(state, expanded);
|
||||
let posX: number, posY: number;
|
||||
const parentCard = sourceSessionId ? state.cards[sourceSessionId] : null;
|
||||
if (parentCard) {
|
||||
const anchorX = parentCard.x + parentCard.width + GRID_GAP * 6;
|
||||
const anchorY = parentCard.y;
|
||||
const pos = findOpenSpotNear(anchorX, anchorY, rects, DEFAULT_WORKFLOW_CARD_W, DEFAULT_WORKFLOW_CARD_H);
|
||||
posX = pos.x;
|
||||
posY = pos.y;
|
||||
} else {
|
||||
const pos = findOpenGridCell(rects, DEFAULT_WORKFLOW_CARD_W, DEFAULT_WORKFLOW_CARD_H);
|
||||
posX = pos.x;
|
||||
posY = pos.y;
|
||||
}
|
||||
state.workflowCards[workflowId] = {
|
||||
workflow_id: workflowId,
|
||||
x: posX,
|
||||
y: posY,
|
||||
width: DEFAULT_WORKFLOW_CARD_W,
|
||||
height: DEFAULT_WORKFLOW_CARD_H,
|
||||
zOrder: state.nextZOrder++,
|
||||
source_session_id: sourceSessionId || null,
|
||||
};
|
||||
state.pendingFocusWorkflowId = workflowId;
|
||||
},
|
||||
|
||||
setWorkflowCardPosition(
|
||||
state,
|
||||
action: PayloadAction<{ workflowId: string; x: number; y: number }>,
|
||||
) {
|
||||
const { workflowId, x, y } = action.payload;
|
||||
const card = state.workflowCards[workflowId];
|
||||
if (card) { card.x = x; card.y = y; }
|
||||
},
|
||||
|
||||
setWorkflowCardSize(
|
||||
state,
|
||||
action: PayloadAction<{ workflowId: string; width: number; height: number }>,
|
||||
) {
|
||||
const { workflowId, width, height } = action.payload;
|
||||
const card = state.workflowCards[workflowId];
|
||||
if (card) {
|
||||
card.width = Math.max(360, width);
|
||||
card.height = Math.max(280, height);
|
||||
}
|
||||
},
|
||||
|
||||
removeWorkflowCard(state, action: PayloadAction<string>) {
|
||||
delete state.workflowCards[action.payload];
|
||||
},
|
||||
|
||||
// Rekey draft- id to the server-assigned id without visually hopping the card.
|
||||
rekeyWorkflowCard(
|
||||
state,
|
||||
action: PayloadAction<{ oldId: string; newId: string }>,
|
||||
) {
|
||||
const { oldId, newId } = action.payload;
|
||||
const card = state.workflowCards[oldId];
|
||||
if (!card) return;
|
||||
delete state.workflowCards[oldId];
|
||||
state.workflowCards[newId] = { ...card, workflow_id: newId };
|
||||
if (state.pendingFocusWorkflowId === oldId) state.pendingFocusWorkflowId = newId;
|
||||
},
|
||||
|
||||
clearPendingFocusWorkflowId(state) {
|
||||
state.pendingFocusWorkflowId = null;
|
||||
},
|
||||
|
||||
openWorkflowsHub(state, action: PayloadAction<{ expandedSessionIds?: string[] } | undefined>) {
|
||||
if (state.workflowsHub) {
|
||||
state.workflowsHub.zOrder = state.nextZOrder++;
|
||||
state.pendingFocusWorkflowsHub = true;
|
||||
return;
|
||||
}
|
||||
const rects = collectOccupiedRects(state, action.payload?.expandedSessionIds);
|
||||
const pos = findOpenGridCell(rects, DEFAULT_WORKFLOWS_HUB_W, DEFAULT_WORKFLOWS_HUB_H);
|
||||
state.workflowsHub = {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: DEFAULT_WORKFLOWS_HUB_W,
|
||||
height: DEFAULT_WORKFLOWS_HUB_H,
|
||||
zOrder: state.nextZOrder++,
|
||||
};
|
||||
state.pendingFocusWorkflowsHub = true;
|
||||
},
|
||||
|
||||
clearPendingFocusWorkflowsHub(state) {
|
||||
state.pendingFocusWorkflowsHub = false;
|
||||
},
|
||||
|
||||
closeWorkflowsHub(state) {
|
||||
state.workflowsHub = null;
|
||||
},
|
||||
|
||||
// The Workflows app is an on-canvas card (like chat/browser/view cards),
|
||||
// backed by the singleton workflowsHub geometry. Opening it creates or
|
||||
// raises that card and pans to it; an optional workflowId deep-links to
|
||||
// that workflow's detail once the card mounts.
|
||||
openWorkflowsApp(state, action: PayloadAction<{ workflowId?: string; expandedSessionIds?: string[] } | undefined>) {
|
||||
state.workflowsAppTarget = action.payload?.workflowId ?? null;
|
||||
if (state.workflowsHub) {
|
||||
state.workflowsHub.zOrder = state.nextZOrder++;
|
||||
state.pendingFocusWorkflowsHub = true;
|
||||
return;
|
||||
}
|
||||
const rects = collectOccupiedRects(state, action.payload?.expandedSessionIds);
|
||||
const pos = findOpenGridCell(rects, DEFAULT_WORKFLOWS_HUB_W, DEFAULT_WORKFLOWS_HUB_H);
|
||||
state.workflowsHub = {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: DEFAULT_WORKFLOWS_HUB_W,
|
||||
height: DEFAULT_WORKFLOWS_HUB_H,
|
||||
zOrder: state.nextZOrder++,
|
||||
};
|
||||
state.pendingFocusWorkflowsHub = true;
|
||||
},
|
||||
|
||||
closeWorkflowsApp(state) {
|
||||
state.workflowsHub = null;
|
||||
state.workflowsAppTarget = null;
|
||||
state.workflowsMonitorId = null;
|
||||
state.workflowsMonitorRunId = null;
|
||||
state.workflowsMonitorCard = null;
|
||||
},
|
||||
|
||||
clearWorkflowsAppTarget(state) {
|
||||
state.workflowsAppTarget = null;
|
||||
},
|
||||
|
||||
// Spawn the Run Monitor as a real canvas card to the right of the window,
|
||||
// tethered back to it. Reuses the window's geometry to place + size it.
|
||||
// runId pins a specific (e.g. history) run; omit it to follow the latest.
|
||||
openWorkflowMonitor(state, action: PayloadAction<{ workflowId: string; runId?: string }>) {
|
||||
state.workflowsMonitorId = action.payload.workflowId;
|
||||
state.workflowsMonitorRunId = action.payload.runId ?? null;
|
||||
const hub = state.workflowsHub;
|
||||
// Keep the existing card position when just switching the run shown.
|
||||
if (!state.workflowsMonitorCard) {
|
||||
state.workflowsMonitorCard = {
|
||||
x: hub ? hub.x + hub.width + 140 : 220,
|
||||
y: hub ? hub.y : 160,
|
||||
width: 520,
|
||||
height: hub ? hub.height : 560,
|
||||
zOrder: state.nextZOrder++,
|
||||
};
|
||||
} else {
|
||||
state.workflowsMonitorCard.zOrder = state.nextZOrder++;
|
||||
}
|
||||
},
|
||||
|
||||
closeWorkflowMonitor(state) {
|
||||
state.workflowsMonitorId = null;
|
||||
state.workflowsMonitorRunId = null;
|
||||
state.workflowsMonitorCard = null;
|
||||
},
|
||||
|
||||
setWorkflowsMonitorPosition(state, action: PayloadAction<{ x: number; y: number }>) {
|
||||
if (!state.workflowsMonitorCard) return;
|
||||
state.workflowsMonitorCard.x = action.payload.x;
|
||||
state.workflowsMonitorCard.y = action.payload.y;
|
||||
},
|
||||
|
||||
setWorkflowsRunContext(state, action: PayloadAction<WorkflowsRunContext>) {
|
||||
state.workflowsRunContext = action.payload;
|
||||
},
|
||||
|
||||
clearWorkflowsRunContext(state) {
|
||||
state.workflowsRunContext = null;
|
||||
},
|
||||
|
||||
setWorkflowsHubPosition(state, action: PayloadAction<{ x: number; y: number }>) {
|
||||
if (!state.workflowsHub) return;
|
||||
state.workflowsHub.x = action.payload.x;
|
||||
state.workflowsHub.y = action.payload.y;
|
||||
},
|
||||
|
||||
setWorkflowsHubSize(state, action: PayloadAction<{ width: number; height: number }>) {
|
||||
if (!state.workflowsHub) return;
|
||||
state.workflowsHub.width = Math.max(720, action.payload.width);
|
||||
state.workflowsHub.height = Math.max(420, action.payload.height);
|
||||
},
|
||||
|
||||
pasteBrowserCard(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
@@ -885,7 +1186,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
moveCards(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' }>;
|
||||
items: Array<{ id: string; type: CardType }>;
|
||||
dx: number;
|
||||
dy: number;
|
||||
}>,
|
||||
@@ -910,6 +1211,17 @@ const dashboardLayoutSlice = createSlice({
|
||||
note.x += dx;
|
||||
note.y += dy;
|
||||
}
|
||||
} else if (item.type === 'workflow') {
|
||||
const card = state.workflowCards[item.id];
|
||||
if (card) {
|
||||
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) {
|
||||
@@ -1037,6 +1349,8 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.cards = {};
|
||||
state.viewCards = {};
|
||||
state.browserCards = {};
|
||||
state.workflowCards = {};
|
||||
state.workflowsHub = null;
|
||||
state.notes = {};
|
||||
state.closedCardPositions = {};
|
||||
state.glowingBrowserCards = {};
|
||||
@@ -1047,6 +1361,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.pendingFocusNoteId = null;
|
||||
state.suspendedBrowserCards = {};
|
||||
state.endingBrowserCards = {};
|
||||
state.pendingFocusWorkflowId = null;
|
||||
},
|
||||
|
||||
},
|
||||
@@ -1073,6 +1388,8 @@ const dashboardLayoutSlice = createSlice({
|
||||
for (const card of Object.values(state.browserCards)) {
|
||||
card.dashboard_id = ownerDashboardId;
|
||||
}
|
||||
state.workflowCards = action.payload.workflowCards || {};
|
||||
state.workflowsHub = action.payload.workflowsHub || null;
|
||||
state.notes = action.payload.notes || {};
|
||||
// Cards boot parked (no guest process, title placeholder); the suspend
|
||||
// hook wakes viewport-sized and agent-driven ones on its first pass.
|
||||
@@ -1089,6 +1406,8 @@ const dashboardLayoutSlice = createSlice({
|
||||
for (const card of Object.values(state.browserCards)) {
|
||||
if (!card.dashboard_id) card.dashboard_id = ownerDashboardId;
|
||||
}
|
||||
addMissingCards(state.workflowCards, action.payload.workflowCards || {}, occupied);
|
||||
if (!state.workflowsHub && action.payload.workflowsHub) state.workflowsHub = action.payload.workflowsHub;
|
||||
addMissingCards(state.notes, action.payload.notes || {}, occupied);
|
||||
}
|
||||
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
|
||||
@@ -1106,6 +1425,10 @@ const dashboardLayoutSlice = createSlice({
|
||||
if (!c.zOrder) c.zOrder = 0;
|
||||
if (c.zOrder > maxZ) maxZ = c.zOrder;
|
||||
}
|
||||
for (const w of Object.values(state.workflowCards)) {
|
||||
if (!w.zOrder) w.zOrder = 0;
|
||||
if (w.zOrder > maxZ) maxZ = w.zOrder;
|
||||
}
|
||||
for (const n of Object.values(state.notes)) {
|
||||
if (!n.zOrder) n.zOrder = 0;
|
||||
if (n.zOrder > maxZ) maxZ = n.zOrder;
|
||||
@@ -1125,6 +1448,10 @@ const dashboardLayoutSlice = createSlice({
|
||||
if (state.cards[id]) delete state.cards[id];
|
||||
if (state.closedCardPositions[id]) delete state.closedCardPositions[id];
|
||||
})
|
||||
.addCase(deleteWorkflowFulfilledAction, (state, action) => {
|
||||
const id = action.payload;
|
||||
if (id && state.workflowCards[id]) delete state.workflowCards[id];
|
||||
})
|
||||
.addCase(launchAndSendFirstMessage.fulfilled, (state, action) => {
|
||||
const { draftId, session } = action.payload;
|
||||
const card = state.cards[draftId];
|
||||
@@ -1178,6 +1505,25 @@ export const {
|
||||
fadeGlowingAgentCard,
|
||||
clearGlowingAgentCard,
|
||||
clearPendingFocusBrowserId,
|
||||
addWorkflowCard,
|
||||
setWorkflowCardPosition,
|
||||
setWorkflowCardSize,
|
||||
removeWorkflowCard,
|
||||
rekeyWorkflowCard,
|
||||
clearPendingFocusWorkflowId,
|
||||
openWorkflowsHub,
|
||||
closeWorkflowsHub,
|
||||
openWorkflowsApp,
|
||||
closeWorkflowsApp,
|
||||
clearWorkflowsAppTarget,
|
||||
openWorkflowMonitor,
|
||||
closeWorkflowMonitor,
|
||||
setWorkflowsMonitorPosition,
|
||||
setWorkflowsRunContext,
|
||||
clearWorkflowsRunContext,
|
||||
setWorkflowsHubPosition,
|
||||
setWorkflowsHubSize,
|
||||
clearPendingFocusWorkflowsHub,
|
||||
addNote,
|
||||
setNotePosition,
|
||||
setNoteSize,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API = `${API_BASE}/workflows`;
|
||||
|
||||
export interface MissedRunItem {
|
||||
id: string;
|
||||
workflow_id: string;
|
||||
workflow_title: string;
|
||||
workflow_icon: string;
|
||||
/** ISO instant the fire was supposed to happen. */
|
||||
scheduled_for: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
items: MissedRunItem[];
|
||||
loading: boolean;
|
||||
toastOpen: boolean;
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
items: [],
|
||||
loading: false,
|
||||
toastOpen: false,
|
||||
};
|
||||
|
||||
export const fetchMissedRuns = createAsyncThunk('missedRuns/fetch', async () => {
|
||||
const res = await fetch(`${API}/missed`);
|
||||
const data = await res.json();
|
||||
return data.missed as MissedRunItem[];
|
||||
});
|
||||
|
||||
export const runMissedRuns = createAsyncThunk('missedRuns/run', async (ids: string[]) => {
|
||||
await fetch(`${API}/missed/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
return ids;
|
||||
});
|
||||
|
||||
export const dismissMissedRuns = createAsyncThunk('missedRuns/dismiss', async (ids: string[]) => {
|
||||
await fetch(`${API}/missed/dismiss`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
return ids;
|
||||
});
|
||||
|
||||
const missedRunsSlice = createSlice({
|
||||
name: 'missedRuns',
|
||||
initialState,
|
||||
reducers: {
|
||||
hideMissedRunsToast(state) {
|
||||
state.toastOpen = false;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchMissedRuns.pending, (state) => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(fetchMissedRuns.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.items = action.payload || [];
|
||||
state.toastOpen = (action.payload?.length ?? 0) > 0;
|
||||
})
|
||||
.addCase(fetchMissedRuns.rejected, (state) => {
|
||||
state.loading = false;
|
||||
});
|
||||
// Both run and dismiss remove the acted-on ids from the list.
|
||||
for (const thunk of [runMissedRuns, dismissMissedRuns]) {
|
||||
builder.addCase(thunk.fulfilled, (state, action) => {
|
||||
const gone = new Set(action.payload);
|
||||
state.items = state.items.filter((m) => !gone.has(m.id));
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const { hideMissedRunsToast } = missedRunsSlice.actions;
|
||||
|
||||
export default missedRunsSlice.reducer;
|
||||
@@ -173,6 +173,19 @@ export const resetSystemPrompt = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
export const dismissMcpSuggestion = createAsyncThunk(
|
||||
'settings/dismissMcpSuggestion',
|
||||
async (ids: string[]) => {
|
||||
const res = await fetch(`${SETTINGS_API}/dismiss-mcp-suggestion`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.settings as AppSettings;
|
||||
}
|
||||
);
|
||||
|
||||
export const browseDirectories = createAsyncThunk(
|
||||
'settings/browseDirectories',
|
||||
async (path: string) => {
|
||||
@@ -303,6 +316,10 @@ const settingsSlice = createSlice({
|
||||
state.data = action.payload;
|
||||
state.draft = null;
|
||||
state.draftTab = null;
|
||||
})
|
||||
.addCase(dismissMcpSuggestion.fulfilled, (state, action) => {
|
||||
state.latestWriteId = action.meta.requestId;
|
||||
state.data = action.payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import updateReducer from './updateSlice';
|
||||
import modelsReducer from './modelsSlice';
|
||||
import interactionReducer from './interactionSlice';
|
||||
import subscriptionsReducer from './subscriptionsSlice';
|
||||
import workflowsReducer from './workflowsSlice';
|
||||
import missedRunsReducer from './missedRunsSlice';
|
||||
import onboardingProgressReducer from '@/shared/state/onboardingProgressSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
@@ -35,6 +37,8 @@ export const store = configureStore({
|
||||
models: modelsReducer,
|
||||
interaction: interactionReducer,
|
||||
subscriptions: subscriptionsReducer,
|
||||
workflows: workflowsReducer,
|
||||
missedRuns: missedRunsReducer,
|
||||
onboardingProgress: onboardingProgressReducer,
|
||||
},
|
||||
// Disable Redux Toolkit's dev-mode invariant middleware (serializable +
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API = `${API_BASE}/workflows`;
|
||||
|
||||
export type PermissionKind = 'notify' | 'text' | 'call';
|
||||
|
||||
export interface PermissionTier {
|
||||
kind: PermissionKind;
|
||||
after_minutes: number;
|
||||
phone?: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleConfig {
|
||||
enabled: boolean;
|
||||
repeat_every: number;
|
||||
repeat_unit: 'minute' | 'hour' | 'day' | 'week' | 'month';
|
||||
on_days: number[];
|
||||
hour: number;
|
||||
minute: number;
|
||||
day_of_month?: number | null;
|
||||
/** Monthly schedules fire on the calendar's last day (28-31) when true. */
|
||||
last_day_of_month?: boolean;
|
||||
timezone: string;
|
||||
/** End conditions; null on both = forever. Scheduler auto-disables on threshold. */
|
||||
ends_at: string | null;
|
||||
max_runs: number | null;
|
||||
runs_count: number;
|
||||
}
|
||||
|
||||
export interface CostEstimate {
|
||||
monthly_usd: number;
|
||||
last_run_usd: number;
|
||||
fires_per_month: number;
|
||||
}
|
||||
|
||||
export interface ActiveRun {
|
||||
workflow_id: string;
|
||||
run_id: string;
|
||||
title: string;
|
||||
started_at: string | null;
|
||||
}
|
||||
|
||||
export interface ActionsConfig {
|
||||
prevent_unused: boolean;
|
||||
freeze: boolean;
|
||||
configured_sets: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowStep {
|
||||
id: string;
|
||||
text: string;
|
||||
/** LLM-generated 3-6 word label shown when the step row is collapsed. The
|
||||
* full `text` is what the agent actually runs; this is just the title. */
|
||||
label?: string | null;
|
||||
/** Disabled steps stay in the list but the executor skips them. Undefined
|
||||
* (legacy records) is treated as enabled. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
/** User-chosen swatch (hex). Null/undefined falls back to the id-hash color. */
|
||||
color?: string | null;
|
||||
/** Soft-delete tombstone (ISO). Set = in Trash. */
|
||||
deleted_at?: string | null;
|
||||
system_prompt: string | null;
|
||||
use_synced_prompt: boolean;
|
||||
steps: WorkflowStep[];
|
||||
actions: ActionsConfig;
|
||||
schedule: ScheduleConfig;
|
||||
permissions: PermissionTier[];
|
||||
source_session_id?: string | null;
|
||||
dashboard_id?: string | null;
|
||||
model: string;
|
||||
mode: string;
|
||||
provider: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_run_at: string | null;
|
||||
last_run_status: 'success' | 'failure' | 'ran_late' | 'running' | 'skipped' | null;
|
||||
last_run_id: string | null;
|
||||
next_run_at: string | null;
|
||||
cost_cap_usd_monthly: number | null;
|
||||
cost_estimate?: CostEstimate;
|
||||
/** Sticky session id for the Edit Agent embedded in the workflow card. */
|
||||
edit_agent_session_id?: string | null;
|
||||
/** Sticky session id for the embedded scheduling agent (cadence -> gated tool call). */
|
||||
schedule_agent_session_id?: string | null;
|
||||
/** Pending Edit-Agent draft of the steps; present only while editing. */
|
||||
draft_steps?: WorkflowStep[] | null;
|
||||
/** True when a draft is staged (server-computed convenience flag). */
|
||||
has_draft?: boolean;
|
||||
/** Most recent Test Agent session for this workflow. */
|
||||
last_test_session_id?: string | null;
|
||||
/** Tool permissions the user answered once and we reuse on later runs so an
|
||||
* unattended scheduled fire doesn't stall on a prompt. tool name -> answer. */
|
||||
remembered_approvals?: Record<string, 'allow' | 'deny'>;
|
||||
step_tool_usage?: Record<string, Record<string, boolean>>;
|
||||
/** Tool names observed in the source chat when this workflow was generated. */
|
||||
source_tools?: string[];
|
||||
/** False once the user explicitly renames the workflow; backend may auto-rename while true. */
|
||||
auto_named?: boolean;
|
||||
/** True while a brand-new "+ New" workflow is still being built and hasn't been saved; hub hides these. */
|
||||
unsaved?: boolean;
|
||||
/** Signature of the steps last validated by a test run (or seeded at chat
|
||||
* conversion). Compared against the current steps to decide whether to warn
|
||||
* before scheduling. See scheduleUtils.needsScheduleTestWarning. */
|
||||
tested_signature?: string | null;
|
||||
/** Suggested cadence from a SuggestConvertToWorkflow tool call (e.g. "every weekday at 9am").
|
||||
* Used to seed the scheduling agent's prompt. Transient draft field only. */
|
||||
suggested_cadence?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowRun {
|
||||
id: string;
|
||||
workflow_id: string;
|
||||
status: 'running' | 'success' | 'failure' | 'ran_late' | 'skipped';
|
||||
scheduled_for: string | null;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
session_id: string | null;
|
||||
error: string | null;
|
||||
cost_usd: number;
|
||||
triggered_by: 'schedule' | 'manual' | 'retry';
|
||||
/** Live "what's the agent doing" subtitle while status is 'running'. */
|
||||
last_tool_label?: string | null;
|
||||
/** Currently-executing 0-based step index while status is 'running';
|
||||
* freezes on the failed step when status flips to 'failure'. */
|
||||
active_step_idx?: number | null;
|
||||
/** True while the user has paused the in-flight agent turn (chat-style
|
||||
* stop/resume). Drives the running card's Pause/Resume button. */
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export type WorkflowRunControlAction = 'pause' | 'resume' | 'stop';
|
||||
|
||||
/** Transient view-only state per card; position lives in dashboardLayoutSlice.workflowCards. */
|
||||
export interface OpenCard {
|
||||
workflowId: string;
|
||||
sourceSessionId?: string | null;
|
||||
draft?: Partial<Workflow> | null;
|
||||
view:
|
||||
| 'preview'
|
||||
| 'saved'
|
||||
| 'edit'
|
||||
| 'history'
|
||||
| 'history_detail'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'scheduling'
|
||||
| 'edit_agent'
|
||||
| 'fix_agent';
|
||||
editFacet?: 'General' | 'Actions' | 'Schedule';
|
||||
historyRunId?: string | null;
|
||||
/** The run id currently surfaced by Running/Completed/Failed views. */
|
||||
runId?: string | null;
|
||||
/** When set, the workflow card is "linked" to a sibling session card via
|
||||
* a labeled arrow chip, and the card footer shifts to Stop Watching /
|
||||
* Stop Viewing / Force Stop. The session id points at the sibling agent. */
|
||||
sidecarSessionId?: string | null;
|
||||
sidecarKind?: 'watching' | 'viewing-completed' | 'viewing-error' | 'testing' | null;
|
||||
/** Per-step expand state for ExpandedView. Stores step ids. */
|
||||
expandedStepIds?: string[];
|
||||
/** One-shot "Schedule this workflow?" prompt shown right after a convert.
|
||||
* Transient: lives only on the just-created card, never on hub-opened ones. */
|
||||
showScheduleNudge?: boolean;
|
||||
/** Pre-seed message for the Fix-with-Agent flow so the EditAgent composer
|
||||
* knows which failure context to lead with. Cleared once consumed. */
|
||||
fixSeed?: { runId: string; stepIdx: number; stepLabel: string; error: string } | null;
|
||||
/** True while the preview-time aux naming call is in flight; drives the
|
||||
* header's subtle pulse on a just-converted draft. */
|
||||
metaLoading?: boolean;
|
||||
/** True once preview-time naming filled a real title, so save trusts the
|
||||
* draft's metadata instead of regenerating it server-side. */
|
||||
metaGenerated?: boolean;
|
||||
}
|
||||
|
||||
export interface RunningToast {
|
||||
workflowId: string;
|
||||
runId: string;
|
||||
workflowTitle: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
items: Record<string, Workflow>;
|
||||
runs: Record<string, WorkflowRun[]>;
|
||||
openCards: Record<string, OpenCard>;
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
paused: boolean;
|
||||
active: ActiveRun[];
|
||||
cloudSmsEnabled: boolean;
|
||||
allRuns: WorkflowRun[];
|
||||
allRunsLoading: boolean;
|
||||
runningToast: RunningToast | null;
|
||||
runControlPending: Record<string, WorkflowRunControlAction>;
|
||||
deleted: Workflow[];
|
||||
deletedLoading: boolean;
|
||||
}
|
||||
|
||||
const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false, runningToast: null, runControlPending: {}, deleted: [], deletedLoading: false };
|
||||
|
||||
function mergeRunIntoState(state: State, r: WorkflowRun) {
|
||||
const arr = state.runs[r.workflow_id] || [];
|
||||
const idx = arr.findIndex((x) => x.id === r.id);
|
||||
const prev = idx >= 0 ? arr[idx] : null;
|
||||
if (idx >= 0) arr[idx] = r; else arr.unshift(r);
|
||||
state.runs[r.workflow_id] = arr.slice(0, 100);
|
||||
// Keep the cross-workflow log (Scheduled tasks history tab) live without a refetch.
|
||||
const aIdx = state.allRuns.findIndex((x) => x.id === r.id);
|
||||
if (aIdx >= 0) state.allRuns[aIdx] = r; else state.allRuns.unshift(r);
|
||||
state.allRuns.sort((a, b) => (a.started_at < b.started_at ? 1 : -1));
|
||||
state.allRuns = state.allRuns.slice(0, 200);
|
||||
const pending = state.runControlPending[r.id];
|
||||
if (
|
||||
(pending === 'pause' && r.paused) ||
|
||||
(pending === 'resume' && !r.paused) ||
|
||||
(pending === 'stop' && r.status !== 'running')
|
||||
) {
|
||||
delete state.runControlPending[r.id];
|
||||
}
|
||||
const wf = state.items[r.workflow_id];
|
||||
if (wf) {
|
||||
wf.last_run_at = r.finished_at || r.started_at;
|
||||
wf.last_run_status = r.status === 'skipped' ? wf.last_run_status : (r.status as Workflow['last_run_status']);
|
||||
wf.last_run_id = r.id;
|
||||
}
|
||||
// Keep the live "Ongoing runs" list in sync off the WS stream: a run that's no
|
||||
// longer running drops out of `active`, a freshly-running one joins. Without
|
||||
// this, `active` only refreshed on the one-shot mount fetch, so finished runs
|
||||
// lingered as "Working…" while the monitor already showed them done.
|
||||
const activeIdx = state.active.findIndex((a) => a.run_id === r.id);
|
||||
if (r.status === 'running') {
|
||||
const entry: ActiveRun = { workflow_id: r.workflow_id, run_id: r.id, title: wf?.title || '', started_at: r.started_at };
|
||||
if (activeIdx >= 0) state.active[activeIdx] = entry; else state.active.unshift(entry);
|
||||
} else if (activeIdx >= 0) {
|
||||
state.active.splice(activeIdx, 1);
|
||||
}
|
||||
// Auto-flip the card view on run state transitions so the user sees
|
||||
// Running while it streams, Completed on success, Failed on failure.
|
||||
// Only nudge from views that the user hasn't actively navigated away
|
||||
// from (saved / running). Edit, history, scheduling etc. stay put.
|
||||
const card = state.openCards[r.workflow_id];
|
||||
// A scheduled run flipping into 'running' fired unattended, so nudge the
|
||||
// user with a clickable toast. Only on the into-running edge (not every
|
||||
// tool-label/step bump), and only for schedule (manual runs they kicked
|
||||
// off themselves don't need a "surprise, it's running" popup).
|
||||
if (r.status === 'running' && r.triggered_by === 'schedule' && (!prev || prev.status !== 'running')) {
|
||||
state.runningToast = {
|
||||
workflowId: r.workflow_id,
|
||||
runId: r.id,
|
||||
workflowTitle: state.items[r.workflow_id]?.title || 'Workflow',
|
||||
};
|
||||
}
|
||||
if (card) {
|
||||
const fromRunnable = card.view === 'saved' || card.view === 'running';
|
||||
if (r.status === 'running' && fromRunnable) {
|
||||
card.view = 'running';
|
||||
card.runId = r.id;
|
||||
} else if (prev && prev.status === 'running' && r.status === 'success' && (card.view === 'running' || card.view === 'saved')) {
|
||||
card.view = 'completed';
|
||||
card.runId = r.id;
|
||||
} else if (prev && prev.status === 'running' && r.status === 'failure' && (card.view === 'running' || card.view === 'saved')) {
|
||||
card.view = 'failed';
|
||||
card.runId = r.id;
|
||||
}
|
||||
// A run that finishes while the user is watching it live becomes a
|
||||
// "viewing" link so the sibling chat stays open with Stop Viewing,
|
||||
// not a stale "watching" arrow pointing at a finished run.
|
||||
if (card.sidecarSessionId && card.sidecarKind === 'watching' && prev && prev.status === 'running') {
|
||||
if (r.status === 'failure') card.sidecarKind = 'viewing-error';
|
||||
else if (r.status === 'success' || r.status === 'ran_late') card.sidecarKind = 'viewing-completed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchWorkflows = createAsyncThunk(
|
||||
'workflows/fetch',
|
||||
async (dashboardId?: string) => {
|
||||
const url = dashboardId ? `${API}/list?dashboard_id=${encodeURIComponent(dashboardId)}` : `${API}/list`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
return data.workflows as Workflow[];
|
||||
},
|
||||
{ condition: (_, { getState }) => !(getState() as { workflows: State }).workflows.loading },
|
||||
);
|
||||
|
||||
export const createWorkflow = createAsyncThunk(
|
||||
'workflows/create',
|
||||
async (body: Partial<Workflow> & { metadata_generated?: boolean }) => {
|
||||
const res = await fetch(`${API}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`create failed ${res.status}`);
|
||||
return (await res.json()) as Workflow;
|
||||
},
|
||||
);
|
||||
|
||||
export interface GeneratedMetadata {
|
||||
title: string;
|
||||
description: string;
|
||||
step_labels: string[];
|
||||
}
|
||||
|
||||
export const generateWorkflowMetadata = createAsyncThunk(
|
||||
'workflows/generateMetadata',
|
||||
async (arg: { steps: Array<{ id: string; text: string }>; model?: string }) => {
|
||||
const res = await fetch(`${API}/generate-metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(arg),
|
||||
});
|
||||
if (!res.ok) throw new Error(`metadata failed ${res.status}`);
|
||||
return (await res.json()) as GeneratedMetadata;
|
||||
},
|
||||
);
|
||||
|
||||
// Merge preview-time generated metadata into a draft card, filling only the
|
||||
// fields the user hasn't typed into so a rename mid-flight survives.
|
||||
export const applyGeneratedMetadata = createAsyncThunk(
|
||||
'workflows/applyGeneratedMetadata',
|
||||
async (arg: { workflowId: string; meta: GeneratedMetadata }, { getState, dispatch }) => {
|
||||
const state = getState() as { workflows: State };
|
||||
const card = state.workflows.openCards[arg.workflowId];
|
||||
if (!card) return;
|
||||
const draft = (card.draft || {}) as Partial<Workflow>;
|
||||
const { meta } = arg;
|
||||
const steps = draft.steps || [];
|
||||
const nextDraft: Partial<Workflow> = { ...draft };
|
||||
let changed = false;
|
||||
if (meta.step_labels && meta.step_labels.length === steps.length) {
|
||||
nextDraft.steps = steps.map((s, i) => (meta.step_labels[i] ? { ...s, label: meta.step_labels[i] } : s));
|
||||
changed = true;
|
||||
}
|
||||
const hasTitle = Boolean(meta.title && meta.title.trim());
|
||||
if (hasTitle && !(draft.title || '').trim()) { nextDraft.title = meta.title; changed = true; }
|
||||
if (meta.description && meta.description.trim() && !(draft.description || '').trim()) {
|
||||
nextDraft.description = meta.description;
|
||||
changed = true;
|
||||
}
|
||||
const patch: Partial<OpenCard> = { metaLoading: false, metaGenerated: hasTitle };
|
||||
if (changed) patch.draft = nextDraft;
|
||||
dispatch(updateWorkflowCard({ workflowId: arg.workflowId, patch }));
|
||||
},
|
||||
);
|
||||
|
||||
// Optimistic concurrency via If-Match: server 409s on stale writes; rejectWithValue lets FE distinguish.
|
||||
export const updateWorkflow = createAsyncThunk<
|
||||
Workflow,
|
||||
{ id: string; patch: Partial<Workflow>; ifMatch?: string | null },
|
||||
{ rejectValue: { kind: 'stale' | 'network' | 'server'; message: string; current_updated_at?: string } }
|
||||
>(
|
||||
'workflows/update',
|
||||
async ({ id, patch, ifMatch }, { rejectWithValue }) => {
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (ifMatch) headers['If-Match'] = ifMatch;
|
||||
const res = await fetch(`${API}/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (res.status === 409) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const detail = (data && (data.detail || data)) || {};
|
||||
return rejectWithValue({
|
||||
kind: 'stale',
|
||||
message: detail.message || 'This workflow changed elsewhere. Reload and try again.',
|
||||
current_updated_at: detail.current_updated_at,
|
||||
});
|
||||
}
|
||||
if (!res.ok) {
|
||||
return rejectWithValue({ kind: 'server', message: `Update failed (${res.status}).` });
|
||||
}
|
||||
return (await res.json()) as Workflow;
|
||||
} catch (e) {
|
||||
return rejectWithValue({ kind: 'network', message: (e as Error)?.message || 'Network error.' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
type CommitDraftArg = string | { id: string; model?: string; keep_session?: boolean };
|
||||
|
||||
export const commitDraft = createAsyncThunk('workflows/commitDraft', async (arg: CommitDraftArg) => {
|
||||
const id = typeof arg === 'string' ? arg : arg.id;
|
||||
// Save-gated: the model the user settled on in the Edit Agent picker is applied
|
||||
// to the workflow's run model here (Discard never reaches this path).
|
||||
const model = typeof arg === 'string' ? undefined : arg.model;
|
||||
// keep_session: the build flow auto-commits steps but must keep the chat open.
|
||||
const keepSession = typeof arg === 'string' ? undefined : arg.keep_session;
|
||||
const body: Record<string, unknown> = {};
|
||||
if (model) body.model = model;
|
||||
if (keepSession) body.keep_session = true;
|
||||
const res = await fetch(`${API}/${id}/draft/commit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`commit failed ${res.status}`);
|
||||
return (await res.json()) as Workflow;
|
||||
});
|
||||
|
||||
export const discardDraft = createAsyncThunk('workflows/discardDraft', async (id: string) => {
|
||||
const res = await fetch(`${API}/${id}/draft/discard`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`discard failed ${res.status}`);
|
||||
return (await res.json()) as Workflow;
|
||||
});
|
||||
|
||||
export const deleteWorkflow = createAsyncThunk('workflows/delete', async (id: string) => {
|
||||
await fetch(`${API}/${id}`, { method: 'DELETE' });
|
||||
return id;
|
||||
});
|
||||
|
||||
export const fetchDeletedWorkflows = createAsyncThunk('workflows/fetchDeleted', async (dashboardId?: string) => {
|
||||
const url = dashboardId ? `${API}/deleted?dashboard_id=${encodeURIComponent(dashboardId)}` : `${API}/deleted`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
return data.workflows as Workflow[];
|
||||
});
|
||||
|
||||
export const restoreWorkflow = createAsyncThunk('workflows/restore', async (id: string) => {
|
||||
const res = await fetch(`${API}/${id}/restore`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`restore failed ${res.status}`);
|
||||
return (await res.json()) as Workflow;
|
||||
});
|
||||
|
||||
export const purgeWorkflow = createAsyncThunk('workflows/purge', async (id: string) => {
|
||||
const res = await fetch(`${API}/${id}/purge`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`purge failed ${res.status}`);
|
||||
return id;
|
||||
});
|
||||
|
||||
type RunWorkflowNowArg = string | { id: string; signature?: string | null };
|
||||
|
||||
export const runWorkflowNow = createAsyncThunk('workflows/run', async (arg: RunWorkflowNowArg) => {
|
||||
const id = typeof arg === 'string' ? arg : arg.id;
|
||||
const signature = typeof arg === 'string' ? null : arg.signature;
|
||||
const res = await fetch(`${API}/${id}/run`, {
|
||||
method: 'POST',
|
||||
...(signature ? {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ signature }),
|
||||
} : {}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`run failed ${res.status}`);
|
||||
const data = await res.json();
|
||||
return {
|
||||
id,
|
||||
run_id: (data.run_id || '') as string,
|
||||
status: (data.status || null) as string | null,
|
||||
error: (data.error || null) as string | null,
|
||||
};
|
||||
});
|
||||
|
||||
export const controlWorkflowRun = createAsyncThunk(
|
||||
'workflows/controlRun',
|
||||
async ({ runId, action }: { runId: string; action: WorkflowRunControlAction }) => {
|
||||
const res = await fetch(`${API}/runs/${encodeURIComponent(runId)}/${action}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`${action} failed ${res.status}`);
|
||||
const data = await res.json();
|
||||
return {
|
||||
runId,
|
||||
action,
|
||||
run: (data.run || null) as WorkflowRun | null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchRuns = createAsyncThunk(
|
||||
'workflows/runs',
|
||||
async (id: string) => {
|
||||
const res = await fetch(`${API}/${id}/runs?limit=50`);
|
||||
const data = await res.json();
|
||||
return { id, runs: data.runs as WorkflowRun[] };
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchAllRuns = createAsyncThunk(
|
||||
'workflows/allRuns',
|
||||
async (limit: number = 200) => {
|
||||
const res = await fetch(`${API}/runs/all?limit=${limit}`);
|
||||
const data = await res.json();
|
||||
return data.runs as WorkflowRun[];
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchPausedState = createAsyncThunk('workflows/paused', async () => {
|
||||
const res = await fetch(`${API}/paused`);
|
||||
const data = await res.json();
|
||||
return Boolean(data.paused);
|
||||
});
|
||||
|
||||
export const fetchActiveRuns = createAsyncThunk('workflows/active', async () => {
|
||||
const res = await fetch(`${API}/active`);
|
||||
const data = await res.json();
|
||||
return (data.active || []) as ActiveRun[];
|
||||
});
|
||||
|
||||
export const setPausedAll = createAsyncThunk('workflows/setPaused', async (paused: boolean) => {
|
||||
const res = await fetch(`${API}/${paused ? 'pause-all' : 'resume-all'}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`pause-all toggle failed ${res.status}`);
|
||||
const data = await res.json();
|
||||
return Boolean(data.paused);
|
||||
});
|
||||
|
||||
export const ackRun = createAsyncThunk('workflows/ackRun', async (runId: string) => {
|
||||
const res = await fetch(`${API}/runs/${encodeURIComponent(runId)}/ack`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`ack failed ${res.status}`);
|
||||
return runId;
|
||||
});
|
||||
|
||||
export const fetchCloudSmsStatus = createAsyncThunk('workflows/cloudSms', async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/cloud/sms/status`);
|
||||
const data = await res.json();
|
||||
return Boolean(data.enabled);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'workflows',
|
||||
initialState,
|
||||
reducers: {
|
||||
openWorkflowCard(state, action: { payload: OpenCard }) {
|
||||
state.openCards[action.payload.workflowId] = action.payload;
|
||||
},
|
||||
updateWorkflowCard(state, action: { payload: { workflowId: string; patch: Partial<OpenCard> } }) {
|
||||
const existing = state.openCards[action.payload.workflowId];
|
||||
if (existing) state.openCards[action.payload.workflowId] = { ...existing, ...action.payload.patch };
|
||||
},
|
||||
closeWorkflowCard(state, action: { payload: string }) {
|
||||
delete state.openCards[action.payload];
|
||||
},
|
||||
rekeyOpenCard(state, action: { payload: { oldId: string; newId: string } }) {
|
||||
const entry = state.openCards[action.payload.oldId];
|
||||
if (!entry) return;
|
||||
delete state.openCards[action.payload.oldId];
|
||||
state.openCards[action.payload.newId] = { ...entry, workflowId: action.payload.newId };
|
||||
},
|
||||
upsertRun(state, action: { payload: WorkflowRun }) {
|
||||
mergeRunIntoState(state, action.payload);
|
||||
},
|
||||
toggleExpandedStep(state, action: { payload: { workflowId: string; stepId: string } }) {
|
||||
const card = state.openCards[action.payload.workflowId];
|
||||
if (!card) return;
|
||||
const arr = card.expandedStepIds || [];
|
||||
const has = arr.includes(action.payload.stepId);
|
||||
card.expandedStepIds = has ? arr.filter((x) => x !== action.payload.stepId) : [...arr, action.payload.stepId];
|
||||
},
|
||||
setCardSidecar(state, action: { payload: { workflowId: string; sessionId: string | null; kind: OpenCard['sidecarKind'] } }) {
|
||||
const card = state.openCards[action.payload.workflowId];
|
||||
if (!card) return;
|
||||
card.sidecarSessionId = action.payload.sessionId;
|
||||
card.sidecarKind = action.payload.kind;
|
||||
},
|
||||
clearFixSeed(state, action: { payload: string }) {
|
||||
const card = state.openCards[action.payload];
|
||||
if (card) card.fixSeed = null;
|
||||
},
|
||||
// Live workflow changes pushed over WS (e.g. the Edit Agent's
|
||||
// add/delete/edit-step tools). Keeps an open card in sync without a
|
||||
// full refetch; idempotent, so a window receiving the echo of its own
|
||||
// edit just re-sets the same data.
|
||||
upsertWorkflow(state, action: { payload: Workflow }) {
|
||||
state.items[action.payload.id] = action.payload;
|
||||
},
|
||||
removeWorkflow(state, action: { payload: string }) {
|
||||
delete state.items[action.payload];
|
||||
delete state.runs[action.payload];
|
||||
state.allRuns = state.allRuns.filter((r) => r.workflow_id !== action.payload);
|
||||
},
|
||||
dismissRunningToast(state) {
|
||||
state.runningToast = null;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchWorkflows.pending, (state) => { state.loading = true; })
|
||||
.addCase(fetchWorkflows.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.loaded = true;
|
||||
state.items = {};
|
||||
for (const w of action.payload) state.items[w.id] = w;
|
||||
})
|
||||
.addCase(fetchWorkflows.rejected, (state) => { state.loading = false; state.loaded = true; })
|
||||
.addCase(createWorkflow.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
|
||||
// Optimistic: reflect the patch in the store immediately so store-driven
|
||||
// UI (the schedule test-first banner, status, etc.) updates the instant
|
||||
// the user edits, not after the PATCH round-trips (which awaits an aux
|
||||
// relabel LLM call server-side). fulfilled overwrites with server truth;
|
||||
// a 409 stale triggers a refetch in useWorkflowPatch.
|
||||
.addCase(updateWorkflow.pending, (state, action) => {
|
||||
const { id, patch } = action.meta.arg;
|
||||
const cur = state.items[id];
|
||||
if (cur) state.items[id] = { ...cur, ...patch };
|
||||
})
|
||||
.addCase(updateWorkflow.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
|
||||
.addCase(commitDraft.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
|
||||
.addCase(discardDraft.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
|
||||
.addCase(deleteWorkflow.fulfilled, (state, action) => {
|
||||
delete state.items[action.payload];
|
||||
delete state.runs[action.payload];
|
||||
state.allRuns = state.allRuns.filter((r) => r.workflow_id !== action.payload);
|
||||
})
|
||||
.addCase(runWorkflowNow.fulfilled, (state, action) => {
|
||||
// Enter the running view the moment the run kicks off, off the run_id
|
||||
// the REST call returns. Don't wait for the workflow:run WS event:
|
||||
// if it's missed or races the view, the Stop/Pause header never shows.
|
||||
const { id, run_id, status } = action.payload;
|
||||
const card = state.openCards[id];
|
||||
if (!card || !run_id || status !== 'running') return;
|
||||
if (['saved', 'running', 'completed', 'failed', 'history', 'history_detail'].includes(card.view)) {
|
||||
card.view = 'running';
|
||||
card.runId = run_id;
|
||||
}
|
||||
})
|
||||
.addCase(controlWorkflowRun.pending, (state, action) => {
|
||||
state.runControlPending[action.meta.arg.runId] = action.meta.arg.action;
|
||||
})
|
||||
.addCase(controlWorkflowRun.fulfilled, (state, action) => {
|
||||
if (action.payload.run) {
|
||||
mergeRunIntoState(state, action.payload.run);
|
||||
}
|
||||
if (action.payload.action !== 'stop') {
|
||||
delete state.runControlPending[action.payload.runId];
|
||||
} else if (action.payload.run && action.payload.run.status !== 'running') {
|
||||
delete state.runControlPending[action.payload.runId];
|
||||
}
|
||||
})
|
||||
.addCase(controlWorkflowRun.rejected, (state, action) => {
|
||||
delete state.runControlPending[action.meta.arg.runId];
|
||||
})
|
||||
.addCase(fetchRuns.fulfilled, (state, action) => {
|
||||
state.runs[action.payload.id] = action.payload.runs;
|
||||
for (const r of action.payload.runs) {
|
||||
const pending = state.runControlPending[r.id];
|
||||
if (
|
||||
(pending === 'pause' && r.paused) ||
|
||||
(pending === 'resume' && !r.paused) ||
|
||||
(pending === 'stop' && r.status !== 'running')
|
||||
) {
|
||||
delete state.runControlPending[r.id];
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(fetchAllRuns.pending, (state) => { state.allRunsLoading = true; })
|
||||
.addCase(fetchAllRuns.fulfilled, (state, action) => {
|
||||
state.allRunsLoading = false;
|
||||
state.allRuns = action.payload;
|
||||
})
|
||||
.addCase(fetchAllRuns.rejected, (state) => { state.allRunsLoading = false; })
|
||||
.addCase(fetchDeletedWorkflows.pending, (state) => { state.deletedLoading = true; })
|
||||
.addCase(fetchDeletedWorkflows.fulfilled, (state, action) => { state.deletedLoading = false; state.deleted = action.payload; })
|
||||
.addCase(fetchDeletedWorkflows.rejected, (state) => { state.deletedLoading = false; })
|
||||
.addCase(restoreWorkflow.fulfilled, (state, action) => {
|
||||
state.items[action.payload.id] = action.payload;
|
||||
state.deleted = state.deleted.filter((w) => w.id !== action.payload.id);
|
||||
})
|
||||
.addCase(purgeWorkflow.fulfilled, (state, action) => {
|
||||
state.deleted = state.deleted.filter((w) => w.id !== action.payload);
|
||||
})
|
||||
.addCase(fetchPausedState.fulfilled, (state, action) => { state.paused = action.payload; })
|
||||
.addCase(setPausedAll.fulfilled, (state, action) => { state.paused = action.payload; })
|
||||
.addCase(fetchActiveRuns.fulfilled, (state, action) => { state.active = action.payload; })
|
||||
.addCase(fetchCloudSmsStatus.fulfilled, (state, action) => { state.cloudSmsEnabled = action.payload; });
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
upsertRun,
|
||||
openWorkflowCard,
|
||||
updateWorkflowCard,
|
||||
closeWorkflowCard,
|
||||
rekeyOpenCard,
|
||||
toggleExpandedStep,
|
||||
setCardSidecar,
|
||||
clearFixSeed,
|
||||
upsertWorkflow,
|
||||
removeWorkflow,
|
||||
dismissRunningToast,
|
||||
} = slice.actions;
|
||||
export default slice.reducer;
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
addApprovalRequest,
|
||||
removeApprovalRequest,
|
||||
updateSessionStatus,
|
||||
setSessionTestState,
|
||||
updateSessionCost,
|
||||
updateSessionContext,
|
||||
setContextOverflow,
|
||||
@@ -24,10 +25,12 @@ import {
|
||||
clearTurnLabel,
|
||||
} from '../state/agentsSlice';
|
||||
import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice';
|
||||
import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeInParentColumn, setBrowserCardPosition, setGlowingBrowserCards } from '../state/dashboardLayoutSlice';
|
||||
import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeInParentColumn, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP, openWorkflowsApp } from '../state/dashboardLayoutSlice';
|
||||
import { upsertOutput } from '../state/outputsSlice';
|
||||
import { fetchSettings } from '../state/settingsSlice';
|
||||
import { displaySessionName } from '../state/sessionDisplay';
|
||||
import { upsertRun, ackRun, runWorkflowNow, openWorkflowCard, upsertWorkflow, removeWorkflow } from '../state/workflowsSlice';
|
||||
import { stepsSignature } from '@/app/pages/Workflows/scheduleUtils';
|
||||
import { getAuthToken } from '../config';
|
||||
import { notifyAgentCompletion } from '../notifications';
|
||||
|
||||
@@ -445,6 +448,13 @@ class WebSocketManager {
|
||||
}
|
||||
|
||||
switch (event) {
|
||||
case 'agent:test_state':
|
||||
// broadcast_global puts everything under data (no top-level session_id).
|
||||
if (data.session_id && data.state) {
|
||||
store.dispatch(setSessionTestState({ sessionId: data.session_id, state: data.state }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'agent:status':
|
||||
// Capture pre-transition status so we only fire a system notification
|
||||
// on a real running→terminal transition. Otherwise a session that
|
||||
@@ -707,6 +717,18 @@ class WebSocketManager {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'agent:out_of_tokens':
|
||||
// Reuses the context_overflow card slot, same "this session is blocked,
|
||||
// here's what to do" shape. Reason field disambiguates from auth/overflow.
|
||||
if (session_id) {
|
||||
store.dispatch(setContextOverflow({
|
||||
sessionId: session_id,
|
||||
reason: 'out_of_tokens',
|
||||
message: data.message ?? "You're out of tokens on this model.",
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'agent:mcp_suggestions':
|
||||
if (session_id) {
|
||||
store.dispatch(setMcpSuggestions({
|
||||
@@ -751,6 +773,16 @@ class WebSocketManager {
|
||||
case 'agent:closed':
|
||||
if (session_id) {
|
||||
const closedStatus = data.status ?? 'stopped';
|
||||
// Don't evict a chat the user is actively watching from a workflow
|
||||
// card; let it settle into a normal completed chat they can continue
|
||||
// or close themselves.
|
||||
const watchedSidecar = Object.values(store.getState().workflows.openCards)
|
||||
.some((oc) => oc.sidecarSessionId === session_id);
|
||||
// The Run Monitor watches a run via its workflow id, not a sidecar:
|
||||
// keep that run's session so the transcript survives completion.
|
||||
const monWf = store.getState().dashboardLayout.workflowsMonitorId;
|
||||
const watchedByMonitor = !!monWf
|
||||
&& (store.getState().workflows.runs[monWf] || []).some((r) => r.session_id === session_id);
|
||||
store.dispatch(closeSessionFromWs({
|
||||
id: session_id,
|
||||
name: data.name ?? 'Untitled',
|
||||
@@ -761,6 +793,7 @@ class WebSocketManager {
|
||||
closed_at: data.closed_at ?? new Date().toISOString(),
|
||||
cost_usd: data.cost_usd ?? 0,
|
||||
dashboard_id: data.dashboard_id,
|
||||
keepSession: watchedSidecar || watchedByMonitor,
|
||||
}));
|
||||
// Auto-delete browsers spawned by this agent when it finishes
|
||||
// normally or errors out. We intentionally skip 'stopped' , the
|
||||
@@ -780,6 +813,77 @@ class WebSocketManager {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'workflow:run':
|
||||
if (data.run) {
|
||||
store.dispatch(upsertRun(data.run));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'workflow:updated':
|
||||
if (data.workflow) {
|
||||
store.dispatch(upsertWorkflow(data.workflow));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'workflow:deleted':
|
||||
if (data.workflow_id) {
|
||||
store.dispatch(removeWorkflow(data.workflow_id));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'workflow:notify':
|
||||
try {
|
||||
notifyAgentCompletion({
|
||||
sessionId: data.session_id || data.workflow_id,
|
||||
sessionName: data.workflow_title || 'Workflow',
|
||||
status: data.status === 'success' ? 'completed' : 'error',
|
||||
});
|
||||
} catch { /* notifications are best-effort */ }
|
||||
try {
|
||||
const w: any = (window as any).openswarm;
|
||||
if (w?.notify) {
|
||||
// Seed by workflow id + current minute so multiple workflows pick different copy
|
||||
// while a single workflow stays stable within a few minutes.
|
||||
const seed = ((data.workflow_id || '').length + Math.floor(Date.now() / 60000)) | 0;
|
||||
const SUCCESS_TITLES = [
|
||||
`${data.workflow_title || 'Workflow'} — done`,
|
||||
`${data.workflow_title || 'Workflow'} just wrapped up`,
|
||||
`Heads up: ${data.workflow_title || 'Workflow'} finished`,
|
||||
`${data.workflow_title || 'Workflow'} is ready`,
|
||||
];
|
||||
const FAILURE_TITLES = [
|
||||
`${data.workflow_title || 'Workflow'} hit a snag`,
|
||||
`${data.workflow_title || 'Workflow'} couldn't finish`,
|
||||
`Something went sideways on ${data.workflow_title || 'Workflow'}`,
|
||||
];
|
||||
const LATE_TITLES = [
|
||||
`${data.workflow_title || 'Workflow'} caught up late`,
|
||||
`${data.workflow_title || 'Workflow'} ran late but made it`,
|
||||
];
|
||||
const pool = data.status === 'success' ? SUCCESS_TITLES
|
||||
: data.status === 'failure' ? FAILURE_TITLES
|
||||
: data.status === 'ran_late' ? LATE_TITLES
|
||||
: [`${data.workflow_title || 'Workflow'} • ${data.status}`];
|
||||
const title = pool[Math.abs(seed) % pool.length];
|
||||
const isMac = (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform));
|
||||
const body = data.tier_kind && data.fallback
|
||||
? `Would have ${data.tier_kind === 'call' ? 'called' : 'texted'} you. (Cloud SMS not wired yet.)`
|
||||
: data.status === 'success'
|
||||
? (isMac ? 'Tap to see what it did.' : 'Click to see what it did.')
|
||||
: data.status === 'failure'
|
||||
? (isMac ? 'Tap to see what went wrong.' : 'Click to see what went wrong.')
|
||||
: (isMac ? 'Tap to open the run.' : 'Click to open the run.');
|
||||
const deepLink = data.workflow_id ? `openswarm://workflow/${data.workflow_id}/run/${data.run_id || ''}` : undefined;
|
||||
const actions = [
|
||||
{ text: 'Looks good', outcome: 'ack' },
|
||||
{ text: 'Re-run', outcome: 'rerun' },
|
||||
{ text: 'Adjust', outcome: 'edit' },
|
||||
];
|
||||
w.notify({ title, body, deepLink, runId: data.run_id, workflowId: data.workflow_id, actions });
|
||||
}
|
||||
} catch { /* native notif optional */ }
|
||||
break;
|
||||
|
||||
case 'dashboard:browser_card_keep':
|
||||
if (data.browser_id) {
|
||||
store.dispatch(keepBrowserCardOpen(data.browser_id));
|
||||
@@ -896,6 +1000,31 @@ class WebSocketManager {
|
||||
|
||||
import { WS_BASE } from '@/shared/config';
|
||||
|
||||
// Bridge native-notification button actions to workflow actions. Subscribe at module
|
||||
// import time so we never miss an early callback fired before any component mounts.
|
||||
(() => {
|
||||
try {
|
||||
const w: any = (typeof window !== 'undefined') ? (window as any).openswarm : null;
|
||||
if (!w?.onNotificationAction) return;
|
||||
w.onNotificationAction(({ outcome, runId, workflowId }: { outcome: string; runId?: string; workflowId?: string }) => {
|
||||
if (!workflowId) return;
|
||||
if (outcome === 'ack' && runId) {
|
||||
store.dispatch(ackRun(runId));
|
||||
return;
|
||||
}
|
||||
if (outcome === 'rerun') {
|
||||
const wf = store.getState().workflows.items[workflowId];
|
||||
store.dispatch(wf ? runWorkflowNow({ id: workflowId, signature: stepsSignature(wf.steps) }) : runWorkflowNow(workflowId));
|
||||
return;
|
||||
}
|
||||
if (outcome === 'edit' || outcome === 'open') {
|
||||
store.dispatch(openWorkflowsApp({ workflowId }));
|
||||
return;
|
||||
}
|
||||
});
|
||||
} catch { /* native notifications optional */ }
|
||||
})();
|
||||
|
||||
export const dashboardWs = new WebSocketManager(`${WS_BASE}/ws/dashboard`, { skipStreamEvents: true });
|
||||
|
||||
// Per-session high-water mark for the resume protocol. Survives across
|
||||
|
||||
Reference in New Issue
Block a user