mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-10-01 14:04:51 +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:
@@ -376,7 +376,7 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
open={!!warning}
|
||||
autoHideDuration={8000}
|
||||
onClose={() => setWarning(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<Alert
|
||||
severity="warning"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
interface Props {
|
||||
// Current title as shown to the user; seeds the edit field.
|
||||
value: string;
|
||||
// Called with the trimmed new title only when it actually changed.
|
||||
onCommit: (next: string) => void;
|
||||
// Layout + text styling shared by the read-only text and the input so the
|
||||
// two states line up (pass flex/font/color here).
|
||||
sx?: SxProps<Theme>;
|
||||
placeholder?: string;
|
||||
// Optional custom display node (e.g. the chat card's Typewriter); falls
|
||||
// back to a plain Typography of `value` when omitted.
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Click-to-rename title. Reads as plain text until clicked, then becomes an
|
||||
// inline input that commits on Enter/blur and cancels on Escape. Lives on
|
||||
// pointer-drag card headers, so it stops pointer propagation (+ data-no-drag)
|
||||
// to avoid starting a card drag while editing.
|
||||
export default function InlineEditableTitle({ value, onCommit, sx, placeholder, children }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const measureRef = useRef<HTMLSpanElement>(null);
|
||||
const [inputWidth, setInputWidth] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!editing) return;
|
||||
const measured = measureRef.current?.getBoundingClientRect().width ?? 0;
|
||||
setInputWidth(Math.ceil(measured) + 2);
|
||||
}, [draft, editing, placeholder]);
|
||||
|
||||
const begin = useCallback(() => { setDraft(value); setEditing(true); }, [value]);
|
||||
|
||||
const commit = useCallback(() => {
|
||||
const t = draft.trim();
|
||||
if (t && t !== value) onCommit(t);
|
||||
setEditing(false);
|
||||
}, [draft, value, onCommit]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
ref={measureRef}
|
||||
component="span"
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
visibility: 'hidden',
|
||||
whiteSpace: 'pre',
|
||||
pointerEvents: 'none',
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{draft || placeholder || ' '}
|
||||
</Box>
|
||||
<InputBase
|
||||
inputRef={inputRef}
|
||||
data-no-drag
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); setEditing(false); }
|
||||
}}
|
||||
sx={{
|
||||
...sx,
|
||||
width: inputWidth ? `${inputWidth}px` : 'auto',
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
'& input': { width: '100%', minWidth: 0, p: 0 },
|
||||
'& input::placeholder': { color: c.text.muted, opacity: 1 },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={begin}
|
||||
// Card headers drag via onPointerDown + preventDefault, which otherwise
|
||||
// swallows this click. Stop the pointer here so the click reaches us and
|
||||
// enters edit mode (same trick the draft-title InputBase uses).
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title="Click to rename"
|
||||
sx={{
|
||||
minWidth: 0, cursor: 'text', borderRadius: 0.5, px: 0.25, mx: -0.25,
|
||||
'&:hover': { bgcolor: c.bg.elevated },
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children ?? (
|
||||
<Typography sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', ...sx }}>
|
||||
{value}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export interface SelectedElement {
|
||||
computedStyles: Record<string, string>;
|
||||
screenshot?: string;
|
||||
boundingRect: { x: number; y: number; width: number; height: number };
|
||||
semanticType?: 'agent-card' | 'message' | 'tool-call' | 'tool-group' | 'view-card' | 'browser-card' | 'settings-option' | 'dom-element';
|
||||
semanticType?: 'agent-card' | 'message' | 'tool-call' | 'tool-group' | 'view-card' | 'browser-card' | 'workflow-card' | 'workflows-hub-card' | 'settings-option' | 'dom-element';
|
||||
semanticLabel?: string;
|
||||
semanticData?: Record<string, any>;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,13 @@ const SELECT_ATTR = 'data-select-type';
|
||||
const SELECT_ID_ATTR = 'data-select-id';
|
||||
const SELECT_META_ATTR = 'data-select-meta';
|
||||
|
||||
const DRAG_SELECT_TYPES = ['agent-card', 'view-card', 'browser-card', 'settings-option'] as const;
|
||||
const DRAG_SELECT_TYPES = ['agent-card', 'view-card', 'browser-card', 'workflow-card', 'settings-option'] as const;
|
||||
const DRAG_SELECTOR = DRAG_SELECT_TYPES.map((t) => `[${SELECT_ATTR}="${t}"]`).join(',');
|
||||
|
||||
// The Workflows app window is a full app surface, not a card you attach as
|
||||
// context, so the selection tool never targets it (neither drag nor click).
|
||||
const NON_SELECTABLE_TYPES = new Set<string>(['workflows-hub-card']);
|
||||
|
||||
export interface OverlayState {
|
||||
visible: boolean;
|
||||
top: number;
|
||||
@@ -38,6 +42,8 @@ const SEMANTIC_LABELS: Record<string, string> = {
|
||||
'tool-group': 'Tool Group',
|
||||
'view-card': 'View',
|
||||
'browser-card': 'Browser',
|
||||
'workflow-card': 'Workflow',
|
||||
'workflows-hub-card': 'Workflows',
|
||||
'settings-option': 'Setting',
|
||||
};
|
||||
|
||||
@@ -45,6 +51,8 @@ function findSelectableAncestor(target: Element, excludeId?: string | null): Ele
|
||||
let current: Element | null = target;
|
||||
while (current) {
|
||||
if (current.hasAttribute(SELECT_ATTR)) {
|
||||
const type = current.getAttribute(SELECT_ATTR);
|
||||
if (type && NON_SELECTABLE_TYPES.has(type)) return null;
|
||||
if (excludeId && current.getAttribute(SELECT_ID_ATTR) === excludeId) return null;
|
||||
return current;
|
||||
}
|
||||
@@ -407,10 +415,22 @@ export function useDomElementSelector(): DomSelectorState {
|
||||
const prevUserSelect = document.body.style.userSelect;
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
// Escape just exits the tool: turn select mode off but leave the already
|
||||
// attached elements alone. Capture + stopPropagation so it doesn't also
|
||||
// clear the canvas selection while the tool is the thing in focus.
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ctx.setSelectMode(false);
|
||||
ctx.setExcludeSelectId(null);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
document.addEventListener('mousedown', handleMouseDown, true);
|
||||
document.addEventListener('mouseup', handleMouseUp, true);
|
||||
document.addEventListener('click', handleClick, true);
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
|
||||
return () => {
|
||||
document.body.style.userSelect = prevUserSelect;
|
||||
@@ -418,6 +438,7 @@ export function useDomElementSelector(): DomSelectorState {
|
||||
document.removeEventListener('mousedown', handleMouseDown, true);
|
||||
document.removeEventListener('mouseup', handleMouseUp, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
if (dragPreviewRafRef.current) cancelAnimationFrame(dragPreviewRafRef.current);
|
||||
setOverlay(EMPTY_OVERLAY);
|
||||
|
||||
@@ -6,6 +6,8 @@ import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import Fade from '@mui/material/Fade';
|
||||
import SwapHorizRoundedIcon from '@mui/icons-material/SwapHorizRounded';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
@@ -17,7 +19,7 @@ import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { friendlyStatusLabel } from '@/shared/statusLabel';
|
||||
import { openSettingsModal } from '@/shared/state/settingsSlice';
|
||||
import { openSettingsModal, dismissMcpSuggestion } from '@/shared/state/settingsSlice';
|
||||
import { API_BASE, getAuthToken } from '@/shared/config';
|
||||
import {
|
||||
sendMessage as sendMessageThunk,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
duplicateSession,
|
||||
setActiveSession,
|
||||
updateSessionModel,
|
||||
clearContextOverflow,
|
||||
updateSessionMode,
|
||||
updateSessionThinkingLevel,
|
||||
updateThinkingLevel,
|
||||
@@ -55,13 +58,18 @@ import MessageActionBar from './shell/MessageActionBar';
|
||||
import ToolCallBubble, { ToolPair } from './tool-bubbles/ToolCallBubble';
|
||||
import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './tool-bubbles/ToolGroupBubble';
|
||||
import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar';
|
||||
import ForceStopAgentBar from './ForceStopAgentBar';
|
||||
import { RateLimitPill } from './shell/RateLimitPill';
|
||||
import ChatInput, { ChatInputHandle } from './ChatInput';
|
||||
import ContextDrawer from './shell/ContextDrawer';
|
||||
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
|
||||
import { ContextPath } from '@/app/components/editor/DirectoryBrowser';
|
||||
import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { WorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setCardSidecar, commitDraft, updateWorkflowCard, controlWorkflowRun } from '@/shared/state/workflowsSlice';
|
||||
import { shallowEqual } from 'react-redux';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { parseMcpToolName, getMcpInputSummary } from '@/shared/mcpToolMeta';
|
||||
|
||||
const CONTEXT_WINDOWS: Record<string, number> = {
|
||||
'opus-4-8': 1_000_000,
|
||||
@@ -232,6 +240,7 @@ interface QueuedMessage {
|
||||
attachedSkills?: Array<{ id: string; name: string; content: string }>;
|
||||
selectedBrowserIds?: string[];
|
||||
selectedAppIds?: string[];
|
||||
attachedRunId?: string;
|
||||
selectedSettingIds?: string[];
|
||||
}
|
||||
|
||||
@@ -244,9 +253,23 @@ interface AgentChatProps {
|
||||
onDismissGlow?: () => void;
|
||||
initialContextPaths?: ContextPath[];
|
||||
onBranch?: (newSessionId: string) => void;
|
||||
// Set when this chat is the workflow build/edit agent: the out-of-tokens card
|
||||
// then warns that switching models here also changes the workflow's run model.
|
||||
workflowEditId?: string;
|
||||
// View-only transcript (e.g. the Run Monitor): renders messages + tool calls
|
||||
// but no composer, so the session can't be typed into.
|
||||
readOnly?: boolean;
|
||||
// One-shot text to drop into the composer (e.g. a run attached as context).
|
||||
prefillPrompt?: string;
|
||||
// A workflow run attached as a removable context chip above the composer; while
|
||||
// present, each send routes through onSendRunQuestion so the run's transcript
|
||||
// rides along as hidden context for that turn.
|
||||
runContext?: WorkflowsRunContext;
|
||||
onClearRunContext?: () => void;
|
||||
onSendRunQuestion?: (prompt: string, runId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose, embedded, autoFocus, isGlowing, onDismissGlow, initialContextPaths, onBranch }) => {
|
||||
const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose, embedded, autoFocus, isGlowing, onDismissGlow, initialContextPaths, onBranch, workflowEditId, readOnly, prefillPrompt, runContext, onClearRunContext, onSendRunQuestion }) => {
|
||||
const c = useClaudeTokens();
|
||||
const STATUS_STYLES: Record<string, { color: string; bg: string }> = {
|
||||
running: { color: c.status.success, bg: c.status.successBg },
|
||||
@@ -257,6 +280,40 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
};
|
||||
const { id: routeId } = useParams<{ id: string }>();
|
||||
const id = sessionIdProp || routeId;
|
||||
// A card linked as a workflow sidecar (Test Agent, or a watched run) swaps
|
||||
// its composer for a Force Stop button: continuing the chat is meaningless,
|
||||
// but killing the run is the common need. Once a Test Agent finishes, the
|
||||
// button flips to a green "close" (see workflow_test_state + ForceStopAgentBar).
|
||||
const linkedSidecar = useAppSelector((s) => {
|
||||
const found = Object.values(s.workflows.openCards).find(
|
||||
(cd) => cd.sidecarSessionId === id && (cd.sidecarKind === 'testing' || cd.sidecarKind === 'watching'),
|
||||
);
|
||||
return found
|
||||
? { workflowId: found.workflowId, runId: found.runId ?? null, kind: found.sidecarKind ?? null }
|
||||
: null;
|
||||
}, shallowEqual);
|
||||
const linkedWorkflowId = linkedSidecar?.workflowId ?? null;
|
||||
const isStoppableSidecar = !!linkedWorkflowId;
|
||||
// A live workflow run being watched owns pause/resume from its workflow
|
||||
// card, so the chat's own "Resume Agent Response" bubble is redundant and
|
||||
// would go stale against the card's Resume. Suppress it for any workflow-run
|
||||
// sidecar, not just the fragile exact "watching" value. Test-run sidecars
|
||||
// keep their chat-level resume behavior.
|
||||
const isWorkflowRunSidecar = useAppSelector((s) => {
|
||||
if (!id) return false;
|
||||
for (const cd of Object.values(s.workflows.openCards)) {
|
||||
if (cd.sidecarSessionId !== id || cd.sidecarKind === 'testing') continue;
|
||||
if (cd.runId) {
|
||||
const run = (s.workflows.runs[cd.workflowId] || []).find((r) => r.id === cd.runId);
|
||||
if (!run || run.session_id === id) return true;
|
||||
}
|
||||
if (cd.sidecarKind === 'watching' || cd.sidecarKind === 'viewing-completed' || cd.sidecarKind === 'viewing-error') return true;
|
||||
}
|
||||
return Object.values(s.workflows.runs).some((runs) =>
|
||||
runs.some((r) => r.session_id === id && r.status === 'running'),
|
||||
);
|
||||
});
|
||||
const testState = useAppSelector((s) => (id ? s.agents.sessions[id]?.workflow_test_state : null) ?? null);
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined));
|
||||
@@ -303,12 +360,29 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
const [heightVersion, setHeightVersion] = useState(0);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const [showResumeBubble, setShowResumeBubble] = useState(false);
|
||||
useEffect(() => {
|
||||
if (isWorkflowRunSidecar) setShowResumeBubble(false);
|
||||
}, [isWorkflowRunSidecar]);
|
||||
const [awaitingResponse, setAwaitingResponse] = useState(false);
|
||||
const [preSendActivityLabel, setPreSendActivityLabel] = useState<string | null>(null);
|
||||
const [activatingMcp, setActivatingMcp] = useState<string | null>(null);
|
||||
const [activateError, setActivateError] = useState<string | null>(null);
|
||||
// Holds the last non-empty suggestions so the docked banner's exit fade renders
|
||||
// them instead of going blank the instant the array is cleared.
|
||||
const mcpSnapshotRef = useRef<Array<{ id: string; title: string; description: string; reason?: string }>>([]);
|
||||
const [mode, setMode] = useState('agent');
|
||||
const [model, setModel] = useState('sonnet');
|
||||
// Workflow build chat only: brief "this model now runs the workflow" notice
|
||||
// when the user switches models, so the run-model change isn't silent.
|
||||
const [workflowModelNotice, setWorkflowModelNotice] = useState<string | null>(null);
|
||||
const workflowModelNoticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Read live in the stable handleSend/dispatchMessage closures without busting
|
||||
// their memo (ChatInput leans on handleSend identity holding across renders).
|
||||
const runContextRef = useRef(runContext);
|
||||
runContextRef.current = runContext;
|
||||
const onSendRunQuestionRef = useRef(onSendRunQuestion);
|
||||
onSendRunQuestionRef.current = onSendRunQuestion;
|
||||
|
||||
const wsRef = useRef<ReturnType<typeof createSessionWs> | null>(null);
|
||||
// Current status for the WS-cleanup closure (effect deps can't include it).
|
||||
@@ -421,6 +495,10 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (msg.attachedRunId && onSendRunQuestionRef.current) {
|
||||
// Run-context question: the backend folds the run transcript into this one
|
||||
// turn and echoes the user bubble + answer over WS, so no optimistic thunk.
|
||||
onSendRunQuestionRef.current(msg.prompt, msg.attachedRunId).catch(() => setAwaitingResponse(false));
|
||||
} else {
|
||||
if (msg.selectedBrowserIds?.length) {
|
||||
dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: id, label: 'Use Browser' }));
|
||||
@@ -461,7 +539,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
didDispatchQueued = true;
|
||||
} else {
|
||||
if (curr === 'stopped') {
|
||||
setShowResumeBubble(true);
|
||||
setShowResumeBubble(!isWorkflowRunSidecar);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +557,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
if (curr !== 'draft' && !didDispatchQueued) {
|
||||
setAwaitingResponse(false);
|
||||
}
|
||||
}, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage]);
|
||||
}, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage, isWorkflowRunSidecar]);
|
||||
|
||||
// A reload remounts past the live running->stopped transition that first shows
|
||||
// the resume button, so re-derive it once from the persisted 'stopped' status
|
||||
@@ -870,7 +948,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
) => {
|
||||
if (!id) return;
|
||||
scrollToBottom();
|
||||
const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds, selectedSettingIds };
|
||||
const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds, selectedSettingIds, attachedRunId: runContextRef.current?.runId };
|
||||
if (agentBusy) {
|
||||
messageQueueRef.current.push(msg);
|
||||
setQueueLength(messageQueueRef.current.length);
|
||||
@@ -887,9 +965,16 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
}, [id, isDraft, dispatch]);
|
||||
|
||||
const handleModelChange = useCallback((newModel: string) => {
|
||||
if (workflowEditId && newModel !== model) {
|
||||
setWorkflowModelNotice(resolveModelLabel(newModel));
|
||||
if (workflowModelNoticeTimer.current) clearTimeout(workflowModelNoticeTimer.current);
|
||||
workflowModelNoticeTimer.current = setTimeout(() => setWorkflowModelNotice(null), 5000);
|
||||
}
|
||||
setModel(newModel);
|
||||
if (id && !isDraft) dispatch(updateSessionModel({ sessionId: id, model: newModel }));
|
||||
}, [id, isDraft, dispatch]);
|
||||
}, [id, isDraft, dispatch, workflowEditId, model, resolveModelLabel]);
|
||||
|
||||
useEffect(() => () => { if (workflowModelNoticeTimer.current) clearTimeout(workflowModelNoticeTimer.current); }, []);
|
||||
|
||||
const handleThinkingLevelChange = useCallback((level: 'off' | 'low' | 'medium' | 'high' | 'auto') => {
|
||||
if (!id) return;
|
||||
@@ -907,8 +992,35 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (!id) return;
|
||||
// A watched live workflow run mirrors the workflow card's Stop: fully stop the
|
||||
// run, not just pause the agent. Test Agent + plain chats stop the session.
|
||||
if (linkedSidecar?.kind === 'watching' && linkedSidecar.runId) {
|
||||
dispatch(controlWorkflowRun({ runId: linkedSidecar.runId, action: 'stop' }));
|
||||
return;
|
||||
}
|
||||
dispatch(stopAgent({ sessionId: id }));
|
||||
}, [id, dispatch]);
|
||||
}, [id, dispatch, linkedSidecar]);
|
||||
|
||||
// Finished Test Agent card: drop the tether + remove this card, and either
|
||||
// commit the workflow draft (Save, same as the edit card's "save now") or
|
||||
// leave the draft untouched so the user keeps editing.
|
||||
const onTestContinueEditing = useCallback(() => {
|
||||
if (linkedWorkflowId) dispatch(setCardSidecar({ workflowId: linkedWorkflowId, sessionId: null, kind: null }));
|
||||
if (id) dispatch(removeCard(id));
|
||||
}, [linkedWorkflowId, id, dispatch]);
|
||||
|
||||
const onTestSaveWorkflow = useCallback(async () => {
|
||||
if (linkedWorkflowId) {
|
||||
try {
|
||||
await dispatch(commitDraft(linkedWorkflowId)).unwrap();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
dispatch(updateWorkflowCard({ workflowId: linkedWorkflowId, patch: { view: 'saved' } }));
|
||||
dispatch(setCardSidecar({ workflowId: linkedWorkflowId, sessionId: null, kind: null }));
|
||||
}
|
||||
if (id) dispatch(removeCard(id));
|
||||
}, [linkedWorkflowId, id, dispatch]);
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
if (!id) return;
|
||||
@@ -1272,7 +1384,9 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
const c = p.call.content;
|
||||
const tool = typeof c === 'object' ? c.tool || '' : '';
|
||||
const input = typeof c === 'object' ? c.input : '';
|
||||
const summary = typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120);
|
||||
const mcp = parseMcpToolName(tool);
|
||||
const friendly = mcp.isMcp ? getMcpInputSummary(input, mcp.action, mcp.serverSlug) : '';
|
||||
const summary = friendly || (typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120));
|
||||
return { tool, input_summary: summary };
|
||||
});
|
||||
dispatch(generateGroupMeta({ sessionId: id, groupId: group.id, toolCalls }));
|
||||
@@ -1284,7 +1398,9 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
const c = p.call.content;
|
||||
const tool = typeof c === 'object' ? c.tool || '' : '';
|
||||
const input = typeof c === 'object' ? c.input : '';
|
||||
const summary = typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120);
|
||||
const mcp = parseMcpToolName(tool);
|
||||
const friendly = mcp.isMcp ? getMcpInputSummary(input, mcp.action, mcp.serverSlug) : '';
|
||||
const summary = friendly || (typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120));
|
||||
return { tool, input_summary: summary };
|
||||
});
|
||||
const resultsSummary = group.pairs
|
||||
@@ -1547,10 +1663,18 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
{session.context_overflow && (() => {
|
||||
const reason = session.context_overflow.reason;
|
||||
const isAuth = reason === 'openswarm_pro_auth_expired' || reason === 'anthropic_auth_invalid' || reason === 'auth_error';
|
||||
const title = isAuth ? 'Sign-in required' : 'Context full';
|
||||
const primaryLabel = isAuth ? 'Open Settings' : 'Start a fresh chat';
|
||||
const isOutOfTokens = reason === 'out_of_tokens';
|
||||
const title = isOutOfTokens ? 'Out of tokens' : isAuth ? 'Sign-in required' : 'Context full';
|
||||
const primaryLabel = isOutOfTokens ? 'Got it' : isAuth ? 'Open Settings' : 'Start a fresh chat';
|
||||
// In the workflow build chat, switching models here also sets the
|
||||
// workflow's scheduled run model, so spell that consequence out.
|
||||
const message = isOutOfTokens && workflowEditId
|
||||
? `${session.context_overflow.message} Whichever model you switch to here becomes the model this workflow runs on.`
|
||||
: session.context_overflow.message;
|
||||
const onPrimary = () => {
|
||||
if (isAuth) {
|
||||
if (isOutOfTokens) {
|
||||
if (id) dispatch(clearContextOverflow({ sessionId: id }));
|
||||
} else if (isAuth) {
|
||||
dispatch(openSettingsModal('models'));
|
||||
} else {
|
||||
const did = session?.dashboard_id;
|
||||
@@ -1570,7 +1694,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: c.text.secondary, display: 'block', mb: 1.25 }}>
|
||||
{session.context_overflow.message}
|
||||
{message}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Typography
|
||||
@@ -1815,7 +1939,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{showResumeBubble && session.status === 'stopped' && (
|
||||
{showResumeBubble && session.status === 'stopped' && !isWorkflowRunSidecar && !readOnly && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
|
||||
<Box
|
||||
onClick={handleResume}
|
||||
@@ -2153,24 +2277,163 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSend={handleSend}
|
||||
disabled={false}
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
model={model}
|
||||
onModelChange={handleModelChange}
|
||||
isRunning={agentBusy}
|
||||
onStop={handleStop}
|
||||
queueLength={queueLength}
|
||||
contextEstimate={contextEstimate}
|
||||
sessionId={id}
|
||||
autoFocus={autoFocus}
|
||||
thinkingLevel={session?.thinking_level ?? 'auto'}
|
||||
onThinkingLevelChange={handleThinkingLevelChange}
|
||||
onActivityLabelChange={setPreSendActivityLabel}
|
||||
/>
|
||||
{(() => {
|
||||
const list = session.mcp_suggestions ?? [];
|
||||
if (list.length) mcpSnapshotRef.current = list;
|
||||
const display = mcpSnapshotRef.current;
|
||||
return (
|
||||
<Fade in={list.length > 0} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Box sx={{
|
||||
mx: 2,
|
||||
mb: 1,
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
bgcolor: c.bg.secondary,
|
||||
position: 'relative',
|
||||
}}>
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Dismiss integration suggestion"
|
||||
onClick={() => {
|
||||
if (!id) return;
|
||||
dispatch(clearMcpSuggestions({ sessionId: id }));
|
||||
dispatch(dismissMcpSuggestion(display.map((s) => s.id)));
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
right: 8,
|
||||
width: 20,
|
||||
height: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1,
|
||||
color: c.text.muted,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 0.75,
|
||||
'&:hover': { color: c.text.primary, bgcolor: c.bg.elevated },
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: c.text.primary, fontWeight: 500, mb: 0.5, pr: 3 }}>
|
||||
Looks like this might need an integration
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: c.text.secondary, display: 'block', mb: 1 }}>
|
||||
Activating one of these will let the agent answer in a single round-trip.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{display.map((s) => (
|
||||
<Box key={s.id} sx={{ flexBasis: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: c.text.primary, fontWeight: 500 }}>
|
||||
{s.title}
|
||||
</Typography>
|
||||
{s.reason && (
|
||||
<Typography variant="caption" sx={{ display: 'block', color: c.text.tertiary }}>
|
||||
{s.reason}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
component="button"
|
||||
variant="caption"
|
||||
disabled={activatingMcp === s.id}
|
||||
onClick={async () => {
|
||||
if (activatingMcp) return;
|
||||
setActivateError(null);
|
||||
setActivatingMcp(s.id);
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
if (tok) headers['Authorization'] = `Bearer ${tok}`;
|
||||
const r = await fetch(`${API_BASE}/mcp-meta/activate`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
server_name: s.id.toLowerCase().replace(/\s+/g, '-'),
|
||||
reason: s.reason || 'preflight suggestion',
|
||||
parent_session_id: session.id,
|
||||
}),
|
||||
});
|
||||
const body = await r.json().catch(() => ({} as any));
|
||||
if (!r.ok) {
|
||||
setActivateError(`Activation failed (${r.status})`);
|
||||
} else if (body?.status === 'unknown_server') {
|
||||
// Not yet connected; jump straight to Actions
|
||||
// so the user can finish OAuth. Nothing here
|
||||
// can do it on their behalf.
|
||||
navigate('/actions');
|
||||
} else if (id) {
|
||||
// Activation succeeded; clear the banner so the user
|
||||
// gets visual confirmation the click did something.
|
||||
dispatch(clearMcpSuggestions({ sessionId: id }));
|
||||
}
|
||||
} catch (e: any) {
|
||||
setActivateError(e?.message || 'Activation failed');
|
||||
} finally {
|
||||
setActivatingMcp(null);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
cursor: activatingMcp === s.id ? 'wait' : 'pointer',
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
borderRadius: 1,
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
bgcolor: 'transparent',
|
||||
color: c.text.primary,
|
||||
opacity: activatingMcp === s.id ? 0.5 : 1,
|
||||
'&:hover': { bgcolor: activatingMcp ? 'transparent' : c.bg.elevated },
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{activatingMcp === s.id ? 'Activating…' : 'Activate'}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{activateError && (
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 0.75, color: c.status.error }}>
|
||||
{activateError}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
})()}
|
||||
{readOnly ? null : isStoppableSidecar ? (
|
||||
<ForceStopAgentBar onStop={handleStop} onSaveWorkflow={onTestSaveWorkflow} onContinueEditing={onTestContinueEditing} testState={testState} />
|
||||
) : (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<WorkflowModelNotice c={c} label={workflowModelNotice} />
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSend={handleSend}
|
||||
disabled={false}
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
model={model}
|
||||
onModelChange={handleModelChange}
|
||||
isRunning={agentBusy}
|
||||
onStop={handleStop}
|
||||
queueLength={queueLength}
|
||||
contextEstimate={contextEstimate}
|
||||
sessionId={id}
|
||||
autoFocus={autoFocus}
|
||||
prefillPrompt={prefillPrompt}
|
||||
placeholderOverride={runContext ? 'Ask about this run...' : undefined}
|
||||
runContext={runContext}
|
||||
onClearRunContext={onClearRunContext}
|
||||
thinkingLevel={session?.thinking_level ?? 'auto'}
|
||||
onThinkingLevelChange={handleThinkingLevelChange}
|
||||
onActivityLabelChange={setPreSendActivityLabel}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</ClickAwayListener>
|
||||
)}
|
||||
@@ -2179,4 +2442,30 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
);
|
||||
};
|
||||
|
||||
// Brief toast above the build chat's composer confirming a model switch also
|
||||
// changes the model the scheduled workflow will run on. Holds the last label in
|
||||
// a ref so the exit fade renders content instead of blanking mid-animation.
|
||||
function WorkflowModelNotice({ c, label }: { c: ReturnType<typeof useClaudeTokens>; label: string | null }) {
|
||||
const last = React.useRef<string | null>(null);
|
||||
if (label) last.current = label;
|
||||
const display = last.current;
|
||||
if (!display) return null;
|
||||
return (
|
||||
<Fade in={!!label} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Box sx={{
|
||||
position: 'absolute', left: 8, right: 8, bottom: 'calc(100% + 8px)',
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`,
|
||||
boxShadow: c.shadow.md, borderRadius: '12px',
|
||||
px: 1.75, py: 1, zIndex: 6,
|
||||
}}>
|
||||
<SwapHorizRoundedIcon sx={{ fontSize: 17, color: c.accent.primary, flexShrink: 0 }} />
|
||||
<Box sx={{ fontSize: '0.83rem', color: c.text.primary, lineHeight: 1.4 }}>
|
||||
This workflow will now be using <b>{display}</b>.
|
||||
</Box>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentChat;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ChatInputView } from './ChatInput/view/ChatInputView';
|
||||
import { PastePreviewDialog } from './ChatInput/view/PastePreviewDialog';
|
||||
import { ICON_MAP, FALLBACK_MODE_BASE } from './ChatInput/modeConfig';
|
||||
import { AttachedImage, ForcedToolGroup, ChatInputHandle } from './ChatInput/types';
|
||||
import type { WorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
export type { AttachedImage, ForcedToolGroup, ChatInputHandle };
|
||||
export type { AttachedSkill } from '@/app/components/editor/richEditorUtils';
|
||||
@@ -46,9 +47,14 @@ interface Props {
|
||||
// Seed the composer with this text (unsent), so a starter-prompt click opens
|
||||
// the chat with the message already typed, ready for the user to hit send.
|
||||
prefillPrompt?: string;
|
||||
// Replaces the default "Agent, @ for context..." placeholder (e.g. "Ask about this run...").
|
||||
placeholderOverride?: string;
|
||||
// A workflow run shown as a small removable chip inside the composer.
|
||||
runContext?: WorkflowsRunContext;
|
||||
onClearRunContext?: () => void;
|
||||
}
|
||||
|
||||
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange, prefillPrompt }, ref) => {
|
||||
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange, prefillPrompt, placeholderOverride, runContext, onClearRunContext }, ref) => {
|
||||
const c = useClaudeTokens();
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -353,6 +359,9 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
isRunning={isRunning}
|
||||
queueLength={queueLength}
|
||||
modeConf={modeConf}
|
||||
placeholderOverride={placeholderOverride}
|
||||
runContext={runContext}
|
||||
onClearRunContext={onClearRunContext}
|
||||
handleInput={handleInput}
|
||||
handleEditorClick={handleEditorClick}
|
||||
handleKeyDown={handleKeyDown}
|
||||
|
||||
@@ -98,6 +98,8 @@ export function appendSelectedElements(trimmed: string, selectedEls: SelectedEle
|
||||
'tool-group': 'Tool Group',
|
||||
'view-card': 'App Card',
|
||||
'browser-card': 'Browser Card',
|
||||
'workflow-card': 'Workflow Card',
|
||||
'workflows-hub-card': 'Workflows Hub',
|
||||
'settings-option': 'Setting',
|
||||
'dom-element': 'Element',
|
||||
}[el.semanticType] || el.semanticType;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ContextPath } from '@/app/components/editor/DirectoryBrowser';
|
||||
import { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { TriggerState } from '@/app/components/editor/richEditorUtils';
|
||||
import { AttachedImage, ForcedToolGroup } from '../types';
|
||||
import type { WorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { SendBlock } from '../hooks/useContextFiles';
|
||||
import { ModelPickerState } from '../hooks/useModelPicker';
|
||||
import { SendBlockBanner } from './SendBlockBanner';
|
||||
@@ -59,6 +60,9 @@ interface Props {
|
||||
isRunning?: boolean;
|
||||
queueLength: number;
|
||||
modeConf: ModeConf;
|
||||
placeholderOverride?: string;
|
||||
runContext?: WorkflowsRunContext;
|
||||
onClearRunContext?: () => void;
|
||||
handleInput: () => void;
|
||||
handleEditorClick: () => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent) => void;
|
||||
@@ -180,6 +184,32 @@ export const ChatInputView: React.FC<Props> = (p) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{p.runContext && (
|
||||
<Box sx={{ display: 'flex', mt: 1, mx: 1.5 }}>
|
||||
<Box
|
||||
title={p.runContext.metaLabel}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.625, maxWidth: '100%',
|
||||
pl: 0.875, pr: 0.5, py: 0.25, borderRadius: '999px',
|
||||
bgcolor: c.bg.secondary, border: `1px solid ${c.border.subtle}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: p.runContext.color, flex: 'none' }} />
|
||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 600, color: c.text.secondary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
Run attached · {p.runContext.title}
|
||||
</Typography>
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Remove run context"
|
||||
onClick={p.onClearRunContext}
|
||||
sx={{ width: 15, height: 15, flex: 'none', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: c.text.tertiary, '&:hover': { color: c.text.secondary } }}
|
||||
>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<AttachmentChips
|
||||
c={c}
|
||||
images={p.images}
|
||||
@@ -207,7 +237,7 @@ export const ChatInputView: React.FC<Props> = (p) => {
|
||||
autoRunMode={p.autoRunMode}
|
||||
isRunning={p.isRunning}
|
||||
queueLength={p.queueLength}
|
||||
placeholderLabel={`${p.modeConf.label}, @ for context, / for commands`}
|
||||
placeholderLabel={p.placeholderOverride ?? `${p.modeConf.label}, @ for context, / for commands`}
|
||||
onInput={p.handleInput}
|
||||
onClick={p.handleEditorClick}
|
||||
onKeyDown={p.handleKeyDown}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Footer for an agent card that's a workflow sidecar (Test Agent, or a
|
||||
// watched run). It replaces the normal composer: while the agent runs you
|
||||
// can't meaningfully chat, but you often want to kill it. Once a Test Agent
|
||||
// finishes, the red "Force Stop" becomes the decision point: keep editing the
|
||||
// workflow, or save the edits (which commits the draft and closes this card).
|
||||
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import StopCircleOutlined from '@mui/icons-material/StopCircleOutlined';
|
||||
import CheckCircleOutlineRounded from '@mui/icons-material/CheckCircleOutlineRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
interface Props {
|
||||
onStop: () => void;
|
||||
onSaveWorkflow: () => void;
|
||||
onContinueEditing: () => void;
|
||||
// 'running' while a test drives the steps; 'complete'/'error' when done.
|
||||
// null for a watched (non-test) run, which only ever offers Force Stop.
|
||||
testState?: 'running' | 'complete' | 'error' | null;
|
||||
}
|
||||
|
||||
export default function ForceStopAgentBar({ onStop, onSaveWorkflow, onContinueEditing, testState }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const done = testState === 'complete' || testState === 'error';
|
||||
|
||||
if (!done) {
|
||||
return (
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onStop}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.6,
|
||||
width: '100%', py: 0.9, borderRadius: 999, cursor: 'pointer',
|
||||
fontSize: '0.85rem', fontWeight: 700,
|
||||
color: c.status.error, bgcolor: c.status.error + '14', border: `1px solid ${c.status.error}55`,
|
||||
'&:hover': { bgcolor: c.status.error + '22' },
|
||||
}}>
|
||||
<StopCircleOutlined sx={{ fontSize: 17 }} />
|
||||
Force Stop Agent
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const tone = testState === 'complete' ? c.status.success : c.status.error;
|
||||
return (
|
||||
<Box sx={{ px: 2, py: 1.5, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: tone, fontSize: '0.8rem', fontWeight: 600 }}>
|
||||
<CheckCircleOutlineRounded sx={{ fontSize: 16 }} />
|
||||
{testState === 'complete' ? 'Test finished' : 'Test stopped'}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onContinueEditing}
|
||||
sx={{
|
||||
flex: 1, textAlign: 'center', py: 0.8, borderRadius: 999, cursor: 'pointer',
|
||||
fontSize: '0.82rem', fontWeight: 600, color: c.text.secondary,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
'&:hover': { bgcolor: c.bg.elevated, color: c.text.primary },
|
||||
}}>
|
||||
Continue editing
|
||||
</Box>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onSaveWorkflow}
|
||||
sx={{
|
||||
flex: 1, textAlign: 'center', py: 0.8, borderRadius: 999, cursor: 'pointer',
|
||||
fontSize: '0.82rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary,
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
Save workflow
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -114,7 +114,17 @@ function parseOpenSwarmError(text: string, ctx?: OverflowContext): OpenSwarmErro
|
||||
ctaAction: 'upgrade',
|
||||
};
|
||||
}
|
||||
// Transient throttle: Anthropic's upstream 429/overload or our own pool-shed. Not the user's
|
||||
if (/provider_rate_limit|account'?s rate limit|session rate limit|This request would exceed your account'?s rate limit/i.test(text)) {
|
||||
const reset = text.match(/reset after ([^)\\.]+)/i)?.[1];
|
||||
return {
|
||||
kind: 'network',
|
||||
title: "You've hit this model's rate limit",
|
||||
detail: reset
|
||||
? `This model can send more requests after ${reset}. Wait for that reset window, or switch to another model.`
|
||||
: 'Wait for the reset window shown by your provider, or switch to another model.',
|
||||
};
|
||||
}
|
||||
// Transient throttle: Anthropic's upstream overload or our own pool-shed. Not the user's
|
||||
// fault and not a plan cap, so don't say "upgrade", just tell them it's busy. claude.ai-style.
|
||||
if (/rate_limit_error|free_pool_busy|overloaded_error|too many requests/i.test(text)) {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AgentMessage } from '@/shared/state/agentsSlice';
|
||||
import { prettyPath, prettyUrl, quoteQuery, bashCommandDetail } from './toolLabels';
|
||||
import { parseMcpToolName, getMcpInputSummary, getGmailHeader } from '@/shared/mcpToolMeta';
|
||||
import { parseMcpToolName, getMcpInputSummary, getGmailHeader, getWorkflowToolInputDisplay } from '@/shared/mcpToolMeta';
|
||||
import { isSettingsWriteTool, isSettingsReadTool, settingsWriteSummary, settingsWriteDisplay } from './settingsToolMeta';
|
||||
|
||||
export function getToolData(call: AgentMessage) {
|
||||
@@ -25,7 +25,7 @@ export function getInputSummary(toolName: string, input: any): string {
|
||||
if (isSettingsReadTool(toolName)) return '';
|
||||
|
||||
const mcp = parseMcpToolName(toolName);
|
||||
if (mcp.isMcp) return getMcpInputSummary(input);
|
||||
if (mcp.isMcp) return getMcpInputSummary(input, mcp.action, mcp.serverSlug);
|
||||
|
||||
const n = toolName.toLowerCase();
|
||||
if (isBashTool(toolName)) {
|
||||
@@ -72,7 +72,10 @@ export function formatInputDisplay(toolName: string, input: any): string {
|
||||
if (isSettingsReadTool(toolName)) return '';
|
||||
|
||||
const mcp = parseMcpToolName(toolName);
|
||||
if (mcp.isMcp) return formatMcpInputDisplay(input);
|
||||
if (mcp.isMcp) {
|
||||
const workflowDisplay = getWorkflowToolInputDisplay(input, mcp.action, mcp.serverSlug);
|
||||
return workflowDisplay || formatMcpInputDisplay(input);
|
||||
}
|
||||
|
||||
const n = toolName.toLowerCase();
|
||||
if (isBashTool(toolName)) return input.command || '';
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ApprovalRequest } from '@/shared/state/agentsSlice';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import { ToolDefinition } from '@/shared/state/toolsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { getMcpInputSummary as getSharedMcpInputSummary, getWorkflowToolInputDisplay, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
|
||||
|
||||
interface IntegrationMeta {
|
||||
label: string;
|
||||
@@ -97,6 +98,9 @@ export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta {
|
||||
);
|
||||
|
||||
if (!toolDef) {
|
||||
if (parsed.serverSlug === 'openswarm-schedule') {
|
||||
return { integration: null, description: '', serverLabel: 'Workflows' };
|
||||
}
|
||||
return { integration: null, description: '', serverLabel: parsed.serverSlug };
|
||||
}
|
||||
|
||||
@@ -111,6 +115,10 @@ export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta {
|
||||
function getMcpInputSummary(actionName: string, toolInput: Record<string, any>): string {
|
||||
const lower = actionName.toLowerCase();
|
||||
|
||||
if (getWorkflowToolLabel(actionName)) {
|
||||
return getSharedMcpInputSummary(toolInput, actionName, 'openswarm-schedule');
|
||||
}
|
||||
|
||||
if (lower.includes('gmail') || lower.includes('email') || lower.includes('mail')) {
|
||||
const query = toolInput.query || toolInput.search_query || toolInput.q || '';
|
||||
const to = toolInput.to || toolInput.recipient || '';
|
||||
@@ -568,7 +576,12 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
|
||||
const meta = useMcpToolMeta(parsed);
|
||||
|
||||
const accentColor = meta.integration?.color || c.status.warning;
|
||||
const displayName = getWorkflowToolLabel(parsed.actionName) || parsed.displayName;
|
||||
const summary = parsed.isMcp ? getMcpInputSummary(parsed.actionName, request.tool_input) : '';
|
||||
const workflowDetails = parsed.isMcp
|
||||
? getWorkflowToolInputDisplay(request.tool_input, parsed.actionName, parsed.serverSlug)
|
||||
: '';
|
||||
const detailText = workflowDetails || JSON.stringify(request.tool_input, null, 2);
|
||||
const isSensitive = !!request.sensitive_pattern;
|
||||
|
||||
if (!parsed.isMcp) {
|
||||
@@ -724,7 +737,7 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.9rem' }}>
|
||||
{parsed.displayName}
|
||||
{displayName}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={meta.serverLabel || parsed.serverSlug}
|
||||
@@ -774,7 +787,6 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
|
||||
sx={{
|
||||
color: c.text.secondary,
|
||||
fontSize: '0.82rem',
|
||||
fontFamily: c.font.mono,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
@@ -792,7 +804,7 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
|
||||
<Collapse in={detailsExpanded || !summary}>
|
||||
<Box sx={{ mt: summary ? 0.75 : 0 }}>
|
||||
<CodeBlock tokens={c}>
|
||||
{JSON.stringify(request.tool_input, null, 2)}
|
||||
{detailText}
|
||||
</CodeBlock>
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { ElapsedTimer, formatElapsed } from '../parsing/toolBubbleChrome';
|
||||
import { useTermColors } from '../parsing/toolColorize';
|
||||
import { ParsedResult } from '../parsing/toolResultParsing';
|
||||
import { isSettingsWriteTool, settingsWriteSummary } from '../parsing/settingsToolMeta';
|
||||
import { McpToolInfo, getMcpShortAction } from '@/shared/mcpToolMeta';
|
||||
import { McpToolInfo, getMcpShortAction, getMcpInputSummary, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
|
||||
import { McpResultCard } from '../mcp-cards/McpResultCard';
|
||||
|
||||
interface CompactMcpBubbleProps {
|
||||
@@ -48,61 +48,70 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
|
||||
const c = useClaudeTokens();
|
||||
const tc = useTermColors();
|
||||
|
||||
const shortAction = mcpInfo.isMcp ? getMcpShortAction(mcpInfo) : toolName;
|
||||
const workflowLabel = mcpInfo.isMcp ? getWorkflowToolLabel(mcpInfo.action) : null;
|
||||
const shortAction = workflowLabel || (mcpInfo.isMcp ? getMcpShortAction(mcpInfo) : toolName);
|
||||
const mcpVerbLabel = (() => {
|
||||
if (workflowLabel) return workflowLabel;
|
||||
const lbl = getToolLabel(toolName, call.id);
|
||||
return result && !isDenied ? lbl.past : lbl.present;
|
||||
})();
|
||||
const serviceLabel = mcpInfo.isMcp ? mcpVerbLabel : shortAction;
|
||||
const inputSummary = mcpInfo.isMcp ? getMcpInputSummary(input, mcpInfo.action, mcpInfo.serverSlug) : '';
|
||||
// A grouped settings write shows the masked change list (input-derived, so it
|
||||
// reads even while pending) instead of the generic "Applied: theme" result line.
|
||||
const visibleSummary = isSettingsWriteTool(toolName)
|
||||
? settingsWriteSummary(input)
|
||||
: (resultSummary || inputSummary);
|
||||
const canToggleDetails = !!visibleSummary;
|
||||
const hideVerbLabel = !!workflowLabel && !!visibleSummary;
|
||||
const ServiceIcon = mcpInfo.isMcp && mcpInfo.service
|
||||
? <GoogleServiceIcon service={mcpInfo.service} size={14} />
|
||||
: null;
|
||||
// A grouped settings write shows the masked change list (input-derived, so it
|
||||
// reads even while pending) instead of the generic "Applied: theme" result line.
|
||||
const headerSummary = isSettingsWriteTool(toolName) ? settingsWriteSummary(input) : resultSummary;
|
||||
|
||||
return (
|
||||
<Box {...selectAttrs} sx={{ my: 0 }}>
|
||||
<Box
|
||||
onClick={toggle}
|
||||
onClick={canToggleDetails ? toggle : undefined}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: showBody ? 'flex-start' : 'center',
|
||||
alignItems: showBody && canToggleDetails ? 'flex-start' : 'center',
|
||||
gap: 0.75,
|
||||
px: 1.5,
|
||||
py: 0.6,
|
||||
cursor: 'pointer',
|
||||
borderBottom: showBody ? `1px solid ${c.border.subtle}` : 'none',
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.02)' },
|
||||
cursor: canToggleDetails ? 'pointer' : 'default',
|
||||
borderBottom: showBody && canToggleDetails ? `1px solid ${c.border.subtle}` : 'none',
|
||||
'&:hover': canToggleDetails ? { bgcolor: 'rgba(0,0,0,0.02)' } : undefined,
|
||||
}}
|
||||
>
|
||||
{ServiceIcon}
|
||||
<Typography
|
||||
sx={{
|
||||
color: c.accent.primary,
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{serviceLabel}
|
||||
</Typography>
|
||||
{headerSummary && !isError && (
|
||||
{!hideVerbLabel && (
|
||||
<Typography
|
||||
sx={{
|
||||
color: c.text.secondary,
|
||||
color: c.accent.primary,
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{serviceLabel}
|
||||
</Typography>
|
||||
)}
|
||||
{visibleSummary && !isError && (
|
||||
<Typography
|
||||
sx={{
|
||||
color: hideVerbLabel ? c.text.primary : c.text.secondary,
|
||||
fontSize: '0.74rem',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
...(showBody
|
||||
...(showBody && canToggleDetails
|
||||
? { whiteSpace: 'normal', wordBreak: 'break-word' }
|
||||
: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }),
|
||||
}}
|
||||
>
|
||||
{headerSummary}
|
||||
{visibleSummary}
|
||||
</Typography>
|
||||
)}
|
||||
{!headerSummary && !showTimer && <Box sx={{ flex: 1 }} />}
|
||||
{!visibleSummary && !showTimer && <Box sx={{ flex: 1 }} />}
|
||||
{showTimer && (
|
||||
<>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
@@ -127,12 +136,14 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{canToggleDetails && (
|
||||
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.15, flexShrink: 0 }}>
|
||||
{showBody ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Collapse in={showBody}>
|
||||
<Collapse in={showBody && canToggleDetails}>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: tc.TERM_BG,
|
||||
|
||||
@@ -61,6 +61,7 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
|
||||
// already on screen. mcpCompact rows opt out (the group's row-fade handles them).
|
||||
const reveal = useMountReveal();
|
||||
const enterStyle = (!mcpCompact && !suppressReveal) ? reveal : {};
|
||||
const canToggleDetails = !!inputSummary && !isStreaming;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -89,16 +90,16 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
|
||||
} as any}
|
||||
>
|
||||
<Box
|
||||
onClick={toggle}
|
||||
onClick={canToggleDetails ? toggle : undefined}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
px: 1.5,
|
||||
py: mcpCompact ? 0.6 : 0.75,
|
||||
cursor: isStreaming ? 'default' : 'pointer',
|
||||
borderBottom: mcpCompact && showBody ? `1px solid ${c.border.subtle}` : 'none',
|
||||
'&:hover': isStreaming ? {} : { bgcolor: 'rgba(0,0,0,0.02)' },
|
||||
cursor: canToggleDetails ? 'pointer' : 'default',
|
||||
borderBottom: mcpCompact && showBody && canToggleDetails ? `1px solid ${c.border.subtle}` : 'none',
|
||||
'&:hover': canToggleDetails ? { bgcolor: 'rgba(0,0,0,0.02)' } : {},
|
||||
}}
|
||||
>
|
||||
{mcpInfo.isMcp && mcpInfo.service
|
||||
@@ -186,7 +187,7 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
|
||||
)}
|
||||
{showTimer && <ElapsedTimer startTime={call.timestamp} />}
|
||||
|
||||
{!isStreaming && (
|
||||
{canToggleDetails && (
|
||||
<IconButton size="small" sx={{ color: c.text.tertiary, p: mcpCompact ? 0.15 : 0.25, flexShrink: 0 }}>
|
||||
{showBody ? (
|
||||
<ExpandLessIcon sx={{ fontSize: mcpCompact ? 16 : 18 }} />
|
||||
@@ -197,7 +198,7 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Collapse in={showBody}>
|
||||
<Collapse in={showBody && canToggleDetails}>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: tc.TERM_BG,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AgentMessage, ToolGroupMeta } from '@/shared/state/agentsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useMountReveal } from './useMountReveal';
|
||||
import { sanitizeSvgString } from '@/shared/sanitizeSvg';
|
||||
import { parseMcpToolName, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
|
||||
import ToolCallBubble, { ToolPair } from './ToolCallBubble';
|
||||
|
||||
export interface ToolGroup {
|
||||
@@ -83,13 +84,21 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
).length;
|
||||
const allDone = pendingCount === 0 || !isSessionRunning;
|
||||
|
||||
const displayName = meta?.name || group.label;
|
||||
const hasSvg = !!meta?.svg;
|
||||
|
||||
const toolNames = group.pairs.map((p) => {
|
||||
const c2 = typeof p.call.content === 'object' ? p.call.content : {};
|
||||
return c2.tool || 'unknown';
|
||||
});
|
||||
const workflowGroupLabel = (() => {
|
||||
if (group.mcpServer !== 'openswarm-schedule') return null;
|
||||
const parsedLabels = Array.from(new Set(toolNames.map((name) => {
|
||||
const parsed = parseMcpToolName(name);
|
||||
return parsed.isMcp ? getWorkflowToolLabel(parsed.action) : null;
|
||||
}).filter(Boolean))) as string[];
|
||||
return parsedLabels.length === 1 ? parsedLabels[0] : 'Workflow actions';
|
||||
})();
|
||||
const displayName = workflowGroupLabel || meta?.name || group.label;
|
||||
const hasSvg = !!meta?.svg && !workflowGroupLabel;
|
||||
const canToggleGroup = group.pairs.length > 1;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -116,15 +125,15 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
onClick={canToggleGroup ? () => setExpanded(!expanded) : undefined}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
px: 1.5,
|
||||
py: 0.7,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.02)' },
|
||||
cursor: canToggleGroup ? 'pointer' : 'default',
|
||||
'&:hover': canToggleGroup ? { bgcolor: 'rgba(0,0,0,0.02)' } : undefined,
|
||||
}}
|
||||
>
|
||||
{!meta ? (
|
||||
@@ -171,9 +180,11 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
{completedCount}/{group.callCount}
|
||||
</Typography>
|
||||
)}
|
||||
{canToggleGroup && (
|
||||
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.15 }}>
|
||||
{expanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
|
||||
@@ -4,10 +4,10 @@ import Typography from '@mui/material/Typography';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Tooltip, { tooltipClasses } from '@mui/material/Tooltip';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Icon from '@mui/material/Icon';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import AddRounded from '@mui/icons-material/AddRounded';
|
||||
import HistoryRounded from '@mui/icons-material/HistoryRounded';
|
||||
|
||||
import ChatBubbleTeardrop from './ChatBubbleTeardrop';
|
||||
|
||||
@@ -22,11 +22,15 @@ const popIn = (i: number) => ({
|
||||
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
|
||||
import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined';
|
||||
import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded';
|
||||
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { motion } from 'framer-motion';
|
||||
import ChatInput from '@/app/pages/AgentChat/ChatInput';
|
||||
import type { ContextPath } from '@/app/components/editor/DirectoryBrowser';
|
||||
import SchedulePopover from '@/app/pages/Workflows/SchedulePopover';
|
||||
import { openWorkflowCard, fetchAllRuns, upsertRun } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard, openWorkflowsApp, closeWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { useElementSelection } from '@/app/components/editor/ElementSelectionContext';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
@@ -180,9 +184,15 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
const [viewSearch, setViewSearch] = useState('');
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [historyQuery, setHistoryQuery] = useState('');
|
||||
const [popoverMode, setPopoverMode] = useState<'search' | 'runs' | 'schedule'>('search');
|
||||
const [expandToast, setExpandToast] = useState<string | null>(null);
|
||||
const shortcut = useAppSelector((s) => s.settings.data.new_agent_shortcut);
|
||||
const outputs = useAppSelector((s) => s.outputs.items);
|
||||
const historySearch = useAppSelector((s) => s.agents.historySearch);
|
||||
const allRuns = useAppSelector((s) => s.workflows.allRuns);
|
||||
const allRunsLoading = useAppSelector((s) => s.workflows.allRunsLoading);
|
||||
const workflowItems = useAppSelector((s) => s.workflows.items);
|
||||
const workflowsHubOpen = useAppSelector((s) => Boolean(s.dashboardLayout.workflowsHub));
|
||||
|
||||
const outputList = useMemo(() => Object.values(outputs), [outputs]);
|
||||
const filteredOutputs = useMemo(() => {
|
||||
@@ -257,20 +267,19 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
setViewSearch('');
|
||||
}, [viewPickerOpen, dispatch]);
|
||||
|
||||
// Opens the History popover on Chat history, with a tab to the Scheduled
|
||||
// tasks run log. The calendar is a separate destination reached via the
|
||||
// Schedule pill, never from here.
|
||||
const handleOpenHistory = useCallback(() => {
|
||||
if (historyOpen) {
|
||||
setHistoryOpen(false);
|
||||
setHistoryQuery('');
|
||||
dispatch(clearHistorySearch());
|
||||
return;
|
||||
}
|
||||
setViewPickerOpen(false);
|
||||
setViewSearch('');
|
||||
setPopoverMode('search');
|
||||
setHistoryOpen(true);
|
||||
setHistoryQuery('');
|
||||
dispatch(clearHistorySearch());
|
||||
dispatch(searchHistory({ q: '', limit: HISTORY_PAGE_SIZE, offset: 0, dashboardId }));
|
||||
}, [historyOpen, dispatch, dashboardId]);
|
||||
}, [historyOpen]);
|
||||
|
||||
const handleHistorySelect = useCallback((sessionId: string) => {
|
||||
onHistoryResume(sessionId);
|
||||
@@ -345,7 +354,8 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) return;
|
||||
|
||||
const el = target instanceof Element ? target : (target as Node).parentElement;
|
||||
if (el?.closest('[role="dialog"], [role="presentation"], .MuiModal-root, .MuiPopover-root')) {
|
||||
|
||||
if (el?.closest('[role="dialog"], [role="presentation"], .MuiModal-root, .MuiPopover-root, [data-toolbar-pills]')) {
|
||||
return;
|
||||
}
|
||||
if (elementSelection?.selectMode && el?.closest('[data-select-type]')) {
|
||||
@@ -404,6 +414,12 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
return () => clearTimeout(timer);
|
||||
}, [historyQuery, historyOpen, dispatch, dashboardId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (historyOpen && popoverMode === 'runs') {
|
||||
dispatch(fetchAllRuns(200));
|
||||
}
|
||||
}, [historyOpen, popoverMode, dispatch]);
|
||||
|
||||
const handleHistoryScroll = useCallback(() => {
|
||||
const el = historyListRef.current;
|
||||
if (!el) return;
|
||||
@@ -431,11 +447,15 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
padding: isExpanded ? '6px' : '5px',
|
||||
userSelect: 'none' as const,
|
||||
overflow: inputOpen || newAgentBounce || historyOpen ? 'visible' : 'hidden',
|
||||
// historyOpen: width owned by the inline history list; leave undefined so framer-motion measures intrinsic size.
|
||||
// historyOpen: width owned by SchedulePopover; leave undefined so framer-motion measures intrinsic size.
|
||||
width: viewPickerOpen ? 580 : historyOpen ? undefined : isExpanded ? 540 : undefined,
|
||||
}}
|
||||
>
|
||||
{inputOpen ? (
|
||||
{inputOpen && !historyOpen ? (
|
||||
// historyOpen wins over the composer: clicking Schedule closes the
|
||||
// composer via onCancel(), but that's a parent-state update that
|
||||
// lands a render late, so without this guard the composer kept
|
||||
// covering the calendar (the "Schedule does nothing" bug).
|
||||
// data-onboarding-scope="dock" makes AC's per-agent resolver prefer this dock chat input over existing agent cards.
|
||||
<div
|
||||
data-onboarding-scope="dock"
|
||||
@@ -456,57 +476,37 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
/>
|
||||
</div>
|
||||
) : historyOpen ? (
|
||||
// Past-chat search list. Fixed-size bordered surface (matches the
|
||||
// toolbar popover footprint) with a search input + scrollable
|
||||
// results; clicking a row resumes that chat.
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', width: 620, maxWidth: 620, flexShrink: 0 }}>
|
||||
<Box sx={{
|
||||
width: '100%',
|
||||
height: 420,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, flexShrink: 0 }}>
|
||||
<SearchIcon sx={{ fontSize: 18, color: c.text.muted }} />
|
||||
<InputBase
|
||||
inputRef={historyInputRef}
|
||||
value={historyQuery}
|
||||
onChange={(e) => setHistoryQuery(e.target.value)}
|
||||
placeholder="Search past chats..."
|
||||
sx={{ flex: 1, fontSize: '0.85rem', color: c.text.primary, fontFamily: c.font.sans, '& input::placeholder': { color: c.text.ghost, opacity: 1 } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
ref={historyListRef}
|
||||
onScroll={handleHistoryScroll}
|
||||
sx={{ flex: 1, overflowY: 'auto', borderTop: `1px solid ${c.border.subtle}` }}
|
||||
>
|
||||
{historySearch.results.length === 0 && !historySearch.loading && (
|
||||
<Typography sx={{ px: 1.5, py: 2.5, fontSize: '0.82rem', color: c.text.muted, textAlign: 'center' }}>
|
||||
{historyQuery ? 'No matching chats' : 'No chat history yet'}
|
||||
</Typography>
|
||||
)}
|
||||
{historySearch.results.map((entry) => (
|
||||
<Box
|
||||
key={entry.id}
|
||||
onClick={() => handleHistorySelect(entry.id)}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.9, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}
|
||||
>
|
||||
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{entry.name}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, flexShrink: 0, whiteSpace: 'nowrap' }}>
|
||||
{formatRelativeTime(entry.closed_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<div style={{ width: '100%' }}>
|
||||
<SchedulePopover
|
||||
mode={popoverMode}
|
||||
onModeChange={setPopoverMode}
|
||||
hideTopChrome
|
||||
chatHistoryOnly
|
||||
historyResults={historySearch.results.map((e) => ({ id: e.id, name: e.name, closed_at: e.closed_at }))}
|
||||
historyLoading={historySearch.loading}
|
||||
historyQuery={historyQuery}
|
||||
onHistoryQueryChange={setHistoryQuery}
|
||||
onHistorySelect={handleHistorySelect}
|
||||
onNewChat={() => { handleCloseHistory(); onNewAgent(); }}
|
||||
onWorkflowSelect={(wid) => {
|
||||
dispatch(openWorkflowsApp({ workflowId: wid }));
|
||||
handleCloseHistory();
|
||||
}}
|
||||
onExpand={() => {
|
||||
dispatch(openWorkflowsApp());
|
||||
handleCloseHistory();
|
||||
}}
|
||||
allRuns={allRuns}
|
||||
allRunsLoading={allRunsLoading}
|
||||
workflowTitleFor={(wid) => workflowItems[wid]?.title || 'Workflow'}
|
||||
onRunOpen={(run) => {
|
||||
dispatch(openWorkflowsApp({ workflowId: run.workflow_id }));
|
||||
handleCloseHistory();
|
||||
}}
|
||||
historyScrollRef={historyListRef as React.RefObject<HTMLDivElement>}
|
||||
onHistoryScroll={handleHistoryScroll}
|
||||
/>
|
||||
</div>
|
||||
) : viewPickerOpen ? (
|
||||
<div style={{ width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1 }}>
|
||||
@@ -750,15 +750,16 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
enterDelay={200}
|
||||
title={
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ fontWeight: 600 }}>History ⌘O</Box>
|
||||
<Box sx={{ fontWeight: 600 }}>Workflows</Box>
|
||||
<Box sx={{ opacity: 0.6, fontSize: '0.7rem', mt: '1px' }}>Schedule and calendar</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="History"
|
||||
aria-label="Workflows"
|
||||
tabIndex={0}
|
||||
onClick={handleOpenHistory}
|
||||
onClick={() => dispatch(workflowsHubOpen ? closeWorkflowsApp() : openWorkflowsApp())}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -766,14 +767,15 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
width: BTN,
|
||||
height: BTN,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
color: c.text.tertiary,
|
||||
color: workflowsHubOpen ? c.accent.primary : c.text.tertiary,
|
||||
bgcolor: workflowsHubOpen ? c.bg.secondary : 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.15s, background-color 0.15s',
|
||||
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
|
||||
...popIn(3),
|
||||
}}
|
||||
>
|
||||
<HistoryRoundedIcon sx={{ fontSize: 22 }} />
|
||||
<EventRepeatIcon sx={{ fontSize: 22 }} />
|
||||
</Box>
|
||||
</WarmTooltip>
|
||||
|
||||
@@ -812,6 +814,40 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
</Box>
|
||||
</WarmTooltip>
|
||||
|
||||
<WarmTooltip
|
||||
tokens={c}
|
||||
placement="top"
|
||||
arrow
|
||||
enterDelay={200}
|
||||
title={
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ fontWeight: 600 }}>History ⌘O</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="History"
|
||||
tabIndex={0}
|
||||
onClick={handleOpenHistory}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: BTN,
|
||||
height: BTN,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
color: c.text.tertiary,
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.15s, background-color 0.15s',
|
||||
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
|
||||
...popIn(5),
|
||||
}}
|
||||
>
|
||||
<HistoryRoundedIcon sx={{ fontSize: 22 }} />
|
||||
</Box>
|
||||
</WarmTooltip>
|
||||
|
||||
{placeholderItems.map(({ icon: PlaceholderIcon, label, sub }) => (
|
||||
<WarmTooltip
|
||||
key={label}
|
||||
@@ -848,6 +884,13 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
</div>
|
||||
)}
|
||||
</MotionBox>
|
||||
<Snackbar
|
||||
open={Boolean(expandToast)}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setExpandToast(null)}
|
||||
message={expandToast || ''}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
ViewCardPosition,
|
||||
BrowserCardPosition,
|
||||
NotePosition,
|
||||
WorkflowCardPosition,
|
||||
WorkflowsHubPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { Output } from '@/shared/state/outputsSlice';
|
||||
import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection';
|
||||
@@ -38,6 +40,8 @@ interface DashboardCanvasProps {
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
notes: Record<string, NotePosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
outputs: Record<string, Output>;
|
||||
glowingAgentCards: Record<string, GlowingAgentCard>;
|
||||
expandedSessionIds: string[];
|
||||
@@ -98,6 +102,8 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
viewCards,
|
||||
browserCards,
|
||||
notes,
|
||||
workflowCards,
|
||||
workflowsHub,
|
||||
outputs,
|
||||
glowingAgentCards,
|
||||
expandedSessionIds,
|
||||
@@ -180,6 +186,8 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
cards={cards}
|
||||
viewCards={viewCards}
|
||||
browserCards={browserCards}
|
||||
workflowCards={workflowCards}
|
||||
workflowsHub={workflowsHub}
|
||||
notes={notes}
|
||||
expandedSessionIds={expandedSessionIds}
|
||||
outputs={outputs}
|
||||
@@ -222,7 +230,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? (
|
||||
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 && Object.keys(workflowCards).length === 0 && !workflowsHub ? (
|
||||
<DashboardEmptyState c={c} onLaunch={onToolbarSend} onStarter={onStarter} />
|
||||
) : (
|
||||
<div
|
||||
@@ -237,10 +245,13 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
{/* Tether lines between branched cards */}
|
||||
<TetherLayer tethers={tethers} c={c} />
|
||||
<DashboardCardLayer
|
||||
dashboardId={dashboardId}
|
||||
cards={cards}
|
||||
viewCards={viewCards}
|
||||
browserCards={browserCards}
|
||||
notes={notes}
|
||||
workflowCards={workflowCards}
|
||||
workflowsHub={workflowsHub}
|
||||
outputs={outputs}
|
||||
glowingAgentCards={glowingAgentCards}
|
||||
expandedSessionIds={expandedSessionIds}
|
||||
@@ -279,6 +290,8 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
cards={cards}
|
||||
viewCards={viewCards}
|
||||
browserCards={browserCards}
|
||||
workflowCards={workflowCards}
|
||||
workflowsHub={workflowsHub}
|
||||
focusedCardId={focusedCardId}
|
||||
shakeDirection={shakeDirection}
|
||||
neighborDirections={neighborDirections}
|
||||
|
||||
@@ -4,6 +4,8 @@ import AgentCard from '../cards/AgentCard';
|
||||
import DashboardViewCard from '../cards/DashboardViewCard';
|
||||
import BrowserCard from '../cards/BrowserCard';
|
||||
import NoteCard from '../cards/NoteCard';
|
||||
import WorkflowsAppCard from '@/app/pages/Workflows/app/WorkflowsAppCard';
|
||||
import RunMonitor from '@/app/pages/Workflows/app/RunMonitor';
|
||||
import {
|
||||
EXPANDED_CARD_MIN_H,
|
||||
DEFAULT_CARD_W,
|
||||
@@ -12,7 +14,11 @@ import {
|
||||
type ViewCardPosition,
|
||||
type BrowserCardPosition,
|
||||
type NotePosition,
|
||||
type WorkflowCardPosition,
|
||||
type WorkflowsHubPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { useAppSelector, useAppDispatch } from '@/shared/hooks';
|
||||
import { closeWorkflowMonitor } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { Output } from '@/shared/state/outputsSlice';
|
||||
import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection';
|
||||
|
||||
@@ -22,10 +28,13 @@ type GlowingAgentCard = { sourceId: string; fading: boolean; sourceYRatio?: numb
|
||||
type Direction = 'left' | 'right' | 'up' | 'down';
|
||||
|
||||
interface DashboardCardLayerProps {
|
||||
dashboardId: string;
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
notes: Record<string, NotePosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
outputs: Record<string, Output>;
|
||||
glowingAgentCards: Record<string, GlowingAgentCard>;
|
||||
expandedSessionIds: string[];
|
||||
@@ -55,10 +64,13 @@ interface DashboardCardLayerProps {
|
||||
}
|
||||
|
||||
const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
dashboardId,
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
notes,
|
||||
workflowCards,
|
||||
workflowsHub,
|
||||
outputs,
|
||||
glowingAgentCards,
|
||||
expandedSessionIds,
|
||||
@@ -86,6 +98,15 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
onBranch,
|
||||
onMeasuredHeight,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const monitorCard = useAppSelector((s) => s.dashboardLayout.workflowsMonitorCard);
|
||||
const monitorWorkflowId = useAppSelector((s) => s.dashboardLayout.workflowsMonitorId);
|
||||
const monitorWorkflow = useAppSelector((s) => (monitorWorkflowId ? s.workflows.items[monitorWorkflowId] : undefined));
|
||||
// The monitor's workflow vanished (trashed/deleted) while open: tear the card
|
||||
// + its tether down instead of leaving an orange line pointing at nothing.
|
||||
React.useEffect(() => {
|
||||
if (monitorCard && !monitorWorkflow) dispatch(closeWorkflowMonitor());
|
||||
}, [monitorCard, monitorWorkflow, dispatch]);
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
@@ -251,6 +272,42 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
onBringToFront={onBringToFront}
|
||||
/>
|
||||
))}
|
||||
{workflowsHub && (
|
||||
<WorkflowsAppCard
|
||||
cardX={workflowsHub.x}
|
||||
cardY={workflowsHub.y}
|
||||
cardWidth={workflowsHub.width}
|
||||
cardHeight={workflowsHub.height}
|
||||
cardZOrder={workflowsHub.zOrder ?? 0}
|
||||
zoom={zoom}
|
||||
panX={panX}
|
||||
panY={panY}
|
||||
isSelected={selection.isSelected('workflows-hub')}
|
||||
isHighlighted={highlightedCardId === 'workflows-hub'}
|
||||
multiDragDelta={selection.isSelected('workflows-hub') ? multiDragDelta : null}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onBringToFront={onBringToFront}
|
||||
/>
|
||||
)}
|
||||
{monitorCard && monitorWorkflow && (
|
||||
<RunMonitor
|
||||
workflow={monitorWorkflow}
|
||||
cardX={monitorCard.x}
|
||||
cardY={monitorCard.y}
|
||||
cardWidth={monitorCard.width}
|
||||
cardHeight={monitorCard.height}
|
||||
cardZOrder={monitorCard.zOrder ?? 0}
|
||||
zoom={zoom}
|
||||
panX={panX}
|
||||
panY={panY}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
/>
|
||||
)}
|
||||
{/* Marquee selection rectangle */}
|
||||
{selection.marquee && (
|
||||
<div
|
||||
|
||||
@@ -11,7 +11,7 @@ import DashboardGlyph from './DashboardGlyph';
|
||||
import ShareButton from '@/app/components/share/ShareButton';
|
||||
import type { AgentSession } from '@/shared/state/agentsSlice';
|
||||
import { saveLayout } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { Output } from '@/shared/state/outputsSlice';
|
||||
import type { CanvasActions } from '../hooks/interaction/useCanvasControls';
|
||||
import { friendlyStatusLabel } from '@/shared/statusLabel';
|
||||
@@ -22,6 +22,8 @@ interface DashboardHeaderProps {
|
||||
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[];
|
||||
outputs: Record<string, Output>;
|
||||
@@ -45,6 +47,8 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
workflowsHub,
|
||||
notes,
|
||||
expandedSessionIds,
|
||||
outputs,
|
||||
@@ -170,7 +174,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
// not be on disk yet. The export reads disk, flush the live
|
||||
// layout now so Share captures the current board, not a stale one.
|
||||
if (!dashboardId) return;
|
||||
dispatch(saveLayout({ dashboardId, cards, viewCards, browserCards, notes, expandedSessionIds }));
|
||||
dispatch(saveLayout({ dashboardId, cards, viewCards, browserCards, workflowCards, workflowsHub, notes, expandedSessionIds }));
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -4,11 +4,15 @@ import DashboardToolbar from '../DashboardToolbar';
|
||||
import CanvasControls from '../controls/CanvasControls';
|
||||
import CardSearchPalette from '../controls/CardSearchPalette';
|
||||
import DirectionHints from '../controls/DirectionHints';
|
||||
import WorkflowRunningToast from '@/app/pages/Workflows/WorkflowRunningToast';
|
||||
import MissedRunsToast from '@/app/pages/Workflows/MissedRunsToast';
|
||||
import type { AgentSession } from '@/shared/state/agentsSlice';
|
||||
import type {
|
||||
CardPosition,
|
||||
ViewCardPosition,
|
||||
BrowserCardPosition,
|
||||
WorkflowCardPosition,
|
||||
WorkflowsHubPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { useCanvasControls } from '../hooks/interaction/useCanvasControls';
|
||||
|
||||
@@ -23,6 +27,8 @@ interface DashboardOverlaysProps {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
focusedCardId: string | null;
|
||||
shakeDirection: Direction | null;
|
||||
neighborDirections: NeighborDirections;
|
||||
@@ -52,6 +58,8 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
workflowsHub,
|
||||
focusedCardId,
|
||||
shakeDirection,
|
||||
neighborDirections,
|
||||
@@ -121,6 +129,8 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
workflowsHub,
|
||||
}}
|
||||
onMinimapPan={(px, py) => canvas.actions.setState({ panX: px, panY: py, zoom: canvas.zoom })}
|
||||
/>
|
||||
@@ -136,6 +146,12 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
|
||||
browserCards={browserCards}
|
||||
sessions={sessions}
|
||||
/>
|
||||
|
||||
{/* Scheduled-run nudge: "your {workflow} is running now" + jump-to-canvas */}
|
||||
<WorkflowRunningToast />
|
||||
|
||||
{/* Launch nudge when scheduled runs elapsed while the app was closed */}
|
||||
<MissedRunsToast />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
handleApproval,
|
||||
collapseSession,
|
||||
closeSession,
|
||||
renameSession,
|
||||
} from '@/shared/state/agentsSlice';
|
||||
import { displayChatTitle, isLegacyAutoName } from '@/shared/state/sessionDisplay';
|
||||
import { Typewriter } from '@/app/components/feedback/Animated';
|
||||
import InlineEditableTitle from '@/app/components/InlineEditableTitle';
|
||||
import {
|
||||
setCardPosition,
|
||||
setCardSize,
|
||||
@@ -38,9 +40,99 @@ import { useDashboardActive } from '@/shared/hooks/useDashboardActive';
|
||||
import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough';
|
||||
import { useStreamingMessage } from '@/shared/state/streamingSlice';
|
||||
import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState';
|
||||
import { setCardSidecar } from '@/shared/state/workflowsSlice';
|
||||
import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime';
|
||||
import { friendlyStatusLabel } from '@/shared/statusLabel';
|
||||
|
||||
/** Extract up to 3 substantive user-prompt steps to seed a workflow. */
|
||||
function isWorkflowSuggestionTool(toolName: unknown, mcpServer?: unknown): boolean {
|
||||
const normalizedTool = String(toolName || '').toLowerCase();
|
||||
const normalizedServer = String(mcpServer || '').toLowerCase();
|
||||
if (!normalizedTool) return false;
|
||||
if (normalizedTool === 'suggestconverttoworkflow') return true;
|
||||
if (normalizedTool.endsWith('__suggestconverttoworkflow')) return true;
|
||||
return normalizedTool.includes('suggestconverttoworkflow') && (
|
||||
normalizedTool.includes('openswarm-schedule') ||
|
||||
normalizedServer.includes('openswarm-schedule')
|
||||
);
|
||||
}
|
||||
|
||||
function isScheduleWorkflowTool(toolName: unknown): boolean {
|
||||
const normalizedTool = String(toolName || '').toLowerCase();
|
||||
if (!normalizedTool) return false;
|
||||
return normalizedTool === 'scheduleworkflow' || normalizedTool.endsWith('__scheduleworkflow');
|
||||
}
|
||||
|
||||
function parseWorkflowSuggestion(text: unknown): { reason: string; cadence: string } | null {
|
||||
if (typeof text !== 'string' || !text.trim()) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed?.reason || typeof parsed.reason !== 'string') return null;
|
||||
return {
|
||||
reason: parsed.reason,
|
||||
cadence: typeof parsed.cadence === 'string'
|
||||
? parsed.cadence
|
||||
: (typeof parsed.suggested_cadence === 'string' ? parsed.suggested_cadence : ''),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseWorkflowSuggestionFromContent(content: any): { reason: string; cadence: string } | null {
|
||||
return parseWorkflowSuggestion(
|
||||
content?.text ??
|
||||
content?.content?.[0]?.text ??
|
||||
content?.result ??
|
||||
content?.output,
|
||||
);
|
||||
}
|
||||
|
||||
/** Detect if the session has a completed SuggestConvertToWorkflow tool call. */
|
||||
function findWorkflowSuggestion(session: AgentSession): { reason: string; cadence: string } | null {
|
||||
let found: { reason: string; cadence: string } | null = null;
|
||||
for (const msg of session.messages || []) {
|
||||
const msgAny = msg as any;
|
||||
const directContent = msgAny.content;
|
||||
if (msgAny.role === 'tool_result') {
|
||||
const toolName = directContent?.tool_name ?? directContent?.tool ?? directContent?.name ?? msgAny.tool_name;
|
||||
if (isWorkflowSuggestionTool(toolName, directContent?.mcpServer ?? msgAny.mcpServer)) {
|
||||
found = parseWorkflowSuggestionFromContent(directContent) || found;
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = Array.isArray(directContent) ? directContent : [];
|
||||
for (const block of blocks) {
|
||||
if (block?.type !== 'tool_result') continue;
|
||||
const toolName = block?.tool_name ?? block?.tool ?? block?.name;
|
||||
if (isWorkflowSuggestionTool(toolName, block?.mcpServer)) {
|
||||
found = parseWorkflowSuggestionFromContent(block) || found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Count completed ScheduleWorkflow tool calls so a new one (vs the mount baseline) can pop the workflow open. */
|
||||
function countScheduleWorkflowCalls(session: AgentSession): number {
|
||||
let count = 0;
|
||||
for (const msg of session.messages || []) {
|
||||
const msgAny = msg as any;
|
||||
const directContent = msgAny.content;
|
||||
if (msgAny.role === 'tool_result') {
|
||||
const toolName = directContent?.tool_name ?? directContent?.tool ?? directContent?.name ?? msgAny.tool_name;
|
||||
if (isScheduleWorkflowTool(toolName)) count += 1;
|
||||
}
|
||||
const blocks = Array.isArray(directContent) ? directContent : [];
|
||||
for (const block of blocks) {
|
||||
if (block?.type !== 'tool_result') continue;
|
||||
if (isScheduleWorkflowTool(block?.tool_name ?? block?.tool ?? block?.name)) count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 16 }) => {
|
||||
if (service === 'gmail') {
|
||||
return (
|
||||
@@ -226,6 +318,47 @@ const AgentCard: React.FC<Props> = ({
|
||||
const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key);
|
||||
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
|
||||
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
|
||||
const workflowSuggestion = useMemo(() => findWorkflowSuggestion(session), [session]);
|
||||
// Suppress the convert-suggestion glow when this chat is already entangled
|
||||
// with a workflow. Two cases:
|
||||
// (a) The session is one of a workflow's runner sessions, OR
|
||||
// (b) The session is the source the workflow was originally derived
|
||||
// from. Either way a fresh convert would just clone the workflow,
|
||||
// which is confusing identity collapse.
|
||||
const workflowRunsMap = useAppSelector((s) => s.workflows.runs);
|
||||
const workflowItems = useAppSelector((s) => s.workflows.items);
|
||||
const linkedWorkflowSidecarId = useAppSelector((s) => {
|
||||
const entry = Object.values(s.workflows.openCards).find((card) => card.sidecarSessionId === session.id);
|
||||
return entry?.workflowId ?? null;
|
||||
});
|
||||
const sourceWorkflow = useMemo(() => {
|
||||
for (const wf of Object.values(workflowItems || {})) {
|
||||
if (wf.source_session_id === session.id) return wf;
|
||||
}
|
||||
return null;
|
||||
}, [workflowItems, session.id]);
|
||||
const isWorkflowRunnerSession = useMemo(() => {
|
||||
// A Test Agent (spawned to validate a workflow draft) isn't a chat to
|
||||
// convert; it carries workflow_test_state.
|
||||
if (session.workflow_test_state) return true;
|
||||
for (const arr of Object.values(workflowRunsMap || {})) {
|
||||
for (const r of arr || []) {
|
||||
if (r.session_id === session.id) return true;
|
||||
}
|
||||
}
|
||||
return Boolean(sourceWorkflow);
|
||||
}, [workflowRunsMap, sourceWorkflow, session.id, session.workflow_test_state]);
|
||||
const hasUserPrompt = useMemo(
|
||||
() => (session.messages || []).some((m) => m.role === 'user' && !m.hidden),
|
||||
[session.messages],
|
||||
);
|
||||
const isConvertBlockedByTurn = session.status !== 'completed' && session.status !== 'stopped';
|
||||
const showConvertToWorkflow =
|
||||
!session.is_welcome_draft &&
|
||||
!isWorkflowRunnerSession &&
|
||||
hasUserPrompt &&
|
||||
(session.messages.length >= 2 || isConvertBlockedByTurn || !!workflowSuggestion);
|
||||
const canConvertToWorkflow = showConvertToWorkflow && !isConvertBlockedByTurn;
|
||||
// Curated picker label with a tidy fallback for unknowns.
|
||||
const friendlyModelLabel = useMemo(() => {
|
||||
const value = session.model;
|
||||
@@ -242,6 +375,41 @@ const AgentCard: React.FC<Props> = ({
|
||||
}, [session.model, modelsByProvider]);
|
||||
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
|
||||
|
||||
const suggestionPulseRef = useRef('');
|
||||
const readyPulseRef = useRef('');
|
||||
useEffect(() => {
|
||||
if (!workflowSuggestion) return;
|
||||
const key = `${workflowSuggestion.reason}|${workflowSuggestion.cadence}`;
|
||||
if (canConvertToWorkflow) {
|
||||
if (readyPulseRef.current === key) return;
|
||||
readyPulseRef.current = key;
|
||||
} else {
|
||||
if (suggestionPulseRef.current === key) return;
|
||||
suggestionPulseRef.current = key;
|
||||
}
|
||||
dispatch(fadeGlowingAgentCard(session.id));
|
||||
}, [workflowSuggestion, canConvertToWorkflow, dispatch, session.id]);
|
||||
|
||||
// When the agent schedules a workflow from this chat, open it in the
|
||||
// Workflows app. Baseline the count once on mount so historical schedules
|
||||
// (e.g. after an app reload) don't re-open on their own.
|
||||
const scheduleWorkflowCount = useMemo(() => countScheduleWorkflowCalls(session), [session]);
|
||||
const baselineScheduleCountRef = useRef<number | null>(null);
|
||||
const autoOpenedWorkflowIdsRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (baselineScheduleCountRef.current === null) {
|
||||
baselineScheduleCountRef.current = scheduleWorkflowCount;
|
||||
return;
|
||||
}
|
||||
if (scheduleWorkflowCount <= baselineScheduleCountRef.current) return;
|
||||
for (const wf of Object.values(workflowItems || {})) {
|
||||
if (wf.source_session_id !== session.id) continue;
|
||||
if (autoOpenedWorkflowIdsRef.current.has(wf.id)) continue;
|
||||
autoOpenedWorkflowIdsRef.current.add(wf.id);
|
||||
dispatch(openWorkflowsApp({ workflowId: wf.id }));
|
||||
}
|
||||
}, [scheduleWorkflowCount, workflowItems, session.id, dispatch]);
|
||||
|
||||
const cardBoxRef = useRef<HTMLDivElement>(null);
|
||||
// Ref so ResizeObserver sees latest value without re-attaching when active flips.
|
||||
const isDashboardActiveRef = useRef(isDashboardActive);
|
||||
@@ -468,6 +636,9 @@ const AgentCard: React.FC<Props> = ({
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (linkedWorkflowSidecarId) {
|
||||
dispatch(setCardSidecar({ workflowId: linkedWorkflowSidecarId, sessionId: null, kind: null }));
|
||||
}
|
||||
dispatch(collapseSession(session.id));
|
||||
dispatch(removeCard(session.id));
|
||||
if (glowEntry) {
|
||||
@@ -746,16 +917,22 @@ const AgentCard: React.FC<Props> = ({
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typewriter
|
||||
<InlineEditableTitle
|
||||
value={displayChatTitle(session)}
|
||||
enabled={!!session.name && !isLegacyAutoName(session.name)}
|
||||
onCommit={(name) => dispatch(renameSession({ sessionId: session.id, name }))}
|
||||
sx={{ flex: '0 1 auto', minWidth: 0, maxWidth: '100%', color: c.text.primary, fontWeight: 600, fontSize: '0.95rem' }}
|
||||
>
|
||||
{(t) => (
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t}
|
||||
</Typography>
|
||||
)}
|
||||
</Typewriter>
|
||||
<Typewriter
|
||||
value={displayChatTitle(session)}
|
||||
enabled={!!session.name && !isLegacyAutoName(session.name)}
|
||||
>
|
||||
{(t) => (
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t}
|
||||
</Typography>
|
||||
)}
|
||||
</Typewriter>
|
||||
</InlineEditableTitle>
|
||||
{/* Status speaks only when it needs the user; finished work sits quiet. The welcome
|
||||
chat hides its 'draft' label so the title reads clean. */}
|
||||
{session.status !== 'completed' && session.status !== 'stopped' && !session.is_welcome_draft && (
|
||||
@@ -816,23 +993,30 @@ const AgentCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
display: isDraft && !expanded ? 'none' : 'flex',
|
||||
gap: 1.5,
|
||||
flexShrink: 0,
|
||||
...(isDraft && { visibility: 'hidden' }),
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
{friendlyModelLabel}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
<ElapsedTimer messages={session.messages} status={session.status} />
|
||||
</Typography>
|
||||
{session.cost_usd > 0 && hasApiKey && (
|
||||
<Typography variant="caption" sx={{ color: c.accent.primary }}>
|
||||
${session.cost_usd.toFixed(4)}
|
||||
<Box
|
||||
sx={{
|
||||
display: isDraft && !expanded ? 'none' : 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
flexShrink: 0,
|
||||
minWidth: 0,
|
||||
...(isDraft && { visibility: 'hidden' }),
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, minWidth: 0, overflow: 'hidden' }}>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary, whiteSpace: 'nowrap' }}>
|
||||
{friendlyModelLabel}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary, whiteSpace: 'nowrap' }}>
|
||||
<ElapsedTimer messages={session.messages} status={session.status} />
|
||||
</Typography>
|
||||
{session.cost_usd > 0 && hasApiKey && (
|
||||
<Typography variant="caption" sx={{ color: c.accent.primary, whiteSpace: 'nowrap' }}>
|
||||
${session.cost_usd.toFixed(4)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef, useCallback, useMemo } from 'react';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
const MINIMAP_W = 200;
|
||||
const MINIMAP_H = 140;
|
||||
@@ -14,6 +14,8 @@ export interface MinimapProps {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
onPan: (panX: number, panY: number) => void;
|
||||
}
|
||||
|
||||
@@ -22,12 +24,12 @@ interface CardRect {
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
type: 'agent' | 'view' | 'browser';
|
||||
type: 'agent' | 'view' | 'browser' | 'workflow' | 'workflows-hub';
|
||||
}
|
||||
|
||||
const Minimap: React.FC<MinimapProps> = ({
|
||||
panX, panY, zoom, viewportRef,
|
||||
cards, viewCards, browserCards,
|
||||
cards, viewCards, browserCards, workflowCards, workflowsHub,
|
||||
onPan,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
@@ -45,8 +47,20 @@ const Minimap: React.FC<MinimapProps> = ({
|
||||
for (const bc of Object.values(browserCards)) {
|
||||
result.push({ x: bc.x, y: bc.y, width: bc.width, height: bc.height, type: 'browser' });
|
||||
}
|
||||
for (const wc of Object.values(workflowCards)) {
|
||||
result.push({ x: wc.x, y: wc.y, width: wc.width, height: wc.height, type: 'workflow' });
|
||||
}
|
||||
if (workflowsHub) {
|
||||
result.push({
|
||||
x: workflowsHub.x,
|
||||
y: workflowsHub.y,
|
||||
width: workflowsHub.width,
|
||||
height: workflowsHub.height,
|
||||
type: 'workflows-hub',
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [cards, viewCards, browserCards]);
|
||||
}, [cards, viewCards, browserCards, workflowCards, workflowsHub]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
const vp = viewportRef.current;
|
||||
@@ -128,11 +142,13 @@ const Minimap: React.FC<MinimapProps> = ({
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}, [minimapToCanvas]);
|
||||
|
||||
const typeColor = (type: 'agent' | 'view' | 'browser') => {
|
||||
const typeColor = (type: CardRect['type']) => {
|
||||
switch (type) {
|
||||
case 'agent': return c.accent.primary;
|
||||
case 'view': return c.status.info;
|
||||
case 'browser': return c.status.success;
|
||||
case 'workflow': return c.status.warning;
|
||||
case 'workflows-hub': return c.status.warning;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import type {
|
||||
CardPosition,
|
||||
ViewCardPosition,
|
||||
BrowserCardPosition,
|
||||
WorkflowCardPosition,
|
||||
WorkflowsHubPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
export interface ContentBounds {
|
||||
@@ -17,11 +19,15 @@ export function computeContentBounds(
|
||||
cards: Record<string, CardPosition>,
|
||||
viewCards: Record<string, ViewCardPosition>,
|
||||
browserCards: Record<string, BrowserCardPosition>,
|
||||
workflowCards: Record<string, WorkflowCardPosition> = {},
|
||||
workflowsHub: WorkflowsHubPosition | null = null,
|
||||
): ContentBounds | undefined {
|
||||
const allRects = [
|
||||
...Object.values(cards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })),
|
||||
...Object.values(viewCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })),
|
||||
...Object.values(browserCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })),
|
||||
...Object.values(workflowCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })),
|
||||
...(workflowsHub ? [{ x: workflowsHub.x, y: workflowsHub.y, w: workflowsHub.width, h: workflowsHub.height }] : []),
|
||||
];
|
||||
if (allRects.length === 0) return undefined;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, type RefObject } from 'react';
|
||||
import type { CardPosition, BrowserCardPosition, ViewCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { EXPANDED_CARD_MIN_H } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardPosition, BrowserCardPosition, ViewCardPosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { Workflow, OpenCard } from '@/shared/state/workflowsSlice';
|
||||
import { EXPANDED_CARD_MIN_H, GRID_GAP } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { AgentSession } from '@/shared/state/agentsSlice';
|
||||
import type { Output } from '@/shared/state/outputsSlice';
|
||||
|
||||
@@ -55,12 +56,33 @@ export function elbowPath(x1: number, y1: number, x2: number, y2: number): strin
|
||||
}
|
||||
|
||||
type Anchor = { x: number; y: number; side: 'left' | 'right' | 'top' | 'bottom' };
|
||||
type CanvasRect = { x: number; y: number; width: number; height: number };
|
||||
|
||||
// Where the ray from a rect's center toward (tx,ty) crosses the rect border.
|
||||
// Pins a tether endpoint to the card edge facing the other card, so it can
|
||||
// never float in empty space the way nearest-corner anchoring could.
|
||||
function borderPoint(x: number, y: number, w: number, h: number, tx: number, ty: number): { x: number; y: number } {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
const dx = tx - cx;
|
||||
const dy = ty - cy;
|
||||
if (dx === 0 && dy === 0) return { x: cx, y: cy };
|
||||
const scale = 1 / Math.max(Math.abs(dx) / (w / 2), Math.abs(dy) / (h / 2));
|
||||
return { x: cx + dx * scale, y: cy + dy * scale };
|
||||
}
|
||||
|
||||
function rectCenter(r: CanvasRect): { x: number; y: number } {
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
}
|
||||
|
||||
interface UseTethersArgs {
|
||||
glowingAgentCards: Record<string, GlowingAgentCard>;
|
||||
glowingBrowserCards: Record<string, GlowingBrowserCard>;
|
||||
cards: Record<string, CardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
workflowItems: Record<string, Workflow>;
|
||||
workflowOpenCards: Record<string, OpenCard>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
outputs: Record<string, Output>;
|
||||
expandedSessionIds: string[];
|
||||
@@ -68,6 +90,9 @@ interface UseTethersArgs {
|
||||
measuredHeightsRef: RefObject<Record<string, number>>;
|
||||
measuredHeightsTick: number;
|
||||
sessionList: AgentSession[];
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
workflowsMonitorCard: WorkflowsHubPosition | null;
|
||||
workflowsMonitorLabel: string;
|
||||
}
|
||||
|
||||
export function useTethers({
|
||||
@@ -75,6 +100,9 @@ export function useTethers({
|
||||
glowingBrowserCards,
|
||||
cards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
workflowItems,
|
||||
workflowOpenCards,
|
||||
viewCards,
|
||||
outputs,
|
||||
expandedSessionIds,
|
||||
@@ -82,8 +110,13 @@ export function useTethers({
|
||||
measuredHeightsRef,
|
||||
measuredHeightsTick,
|
||||
sessionList,
|
||||
workflowsHub,
|
||||
workflowsMonitorCard,
|
||||
workflowsMonitorLabel,
|
||||
}: UseTethersArgs): Tether[] {
|
||||
return useMemo(() => {
|
||||
const wfHeight = (wc: WorkflowCardPosition): number =>
|
||||
measuredHeightsRef.current![wc.workflow_id] ?? wc.height;
|
||||
const agentTethers = Object.entries(glowingAgentCards).map(([copyId, { sourceId, fading, label }]) => {
|
||||
const src = cards[sourceId];
|
||||
const dst = cards[copyId];
|
||||
@@ -249,6 +282,167 @@ export function useTethers({
|
||||
|
||||
const browserTethers = Array.from(glowTethers.values()).filter(Boolean) as Tether[];
|
||||
|
||||
// Workflow tethers reuse the browser-tether anchor/elbow math; skip deleted workflows to avoid dangling arrows.
|
||||
const workflowTethers: Tether[] = [];
|
||||
for (const wc of Object.values(workflowCards)) {
|
||||
const sourceId = wc.source_session_id;
|
||||
if (!sourceId) continue;
|
||||
const src = cards[sourceId];
|
||||
if (!src) continue;
|
||||
// Layout entry can outlive its workflow when deleted from the hub.
|
||||
const hasReal = wc.workflow_id in workflowItems;
|
||||
const hasDraft = wc.workflow_id in workflowOpenCards;
|
||||
if (!hasReal && !hasDraft) continue;
|
||||
// "Make workflow" is a draft-time affordance; once saved (openCard leaves 'preview') the link retires.
|
||||
const openCard = workflowOpenCards[wc.workflow_id];
|
||||
if (openCard && openCard.view !== 'preview') continue;
|
||||
|
||||
let srcX = src.x, srcY = src.y;
|
||||
let dstX = wc.x, dstY = wc.y;
|
||||
if (liveDragInfo) {
|
||||
if (liveDragInfo.cardId === sourceId) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; }
|
||||
if (liveDragInfo.cardId === wc.workflow_id) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; }
|
||||
}
|
||||
|
||||
const srcMeasured = measuredHeightsRef.current![sourceId];
|
||||
const srcH = srcMeasured ?? (expandedSessionIds.includes(sourceId)
|
||||
? Math.max(EXPANDED_CARD_MIN_H, src.height)
|
||||
: src.height);
|
||||
|
||||
const wcH = wfHeight(wc);
|
||||
const srcCx = srcX + src.width / 2;
|
||||
const dstCx = dstX + wc.width / 2;
|
||||
const srcAnchors: Anchor[] = [
|
||||
{ x: srcX + src.width, y: srcY + srcH * 0.54, side: 'right' },
|
||||
{ x: srcX, y: srcY + srcH * 0.54, side: 'left' },
|
||||
{ x: srcCx, y: srcY, side: 'top' },
|
||||
{ x: srcCx, y: srcY + srcH, side: 'bottom' },
|
||||
];
|
||||
const dstAnchors: Anchor[] = [
|
||||
{ x: dstX, y: dstY + wcH * 0.54, side: 'left' },
|
||||
{ x: dstX + wc.width, y: dstY + wcH * 0.54, side: 'right' },
|
||||
{ x: dstCx, y: dstY, side: 'top' },
|
||||
{ x: dstCx, y: dstY + wcH, side: 'bottom' },
|
||||
];
|
||||
let bestSrc = srcAnchors[0], bestDst = dstAnchors[0];
|
||||
let bestDist = Infinity;
|
||||
for (const sa of srcAnchors) {
|
||||
for (const da of dstAnchors) {
|
||||
const d = Math.hypot(sa.x - da.x, sa.y - da.y);
|
||||
if (d < bestDist) { bestDist = d; bestSrc = sa; bestDst = da; }
|
||||
}
|
||||
}
|
||||
const x1 = bestSrc.x, y1 = bestSrc.y;
|
||||
const x2 = bestDst.x, y2 = bestDst.y;
|
||||
const isVertical = (bestSrc.side === 'top' || bestSrc.side === 'bottom')
|
||||
&& (bestDst.side === 'top' || bestDst.side === 'bottom');
|
||||
let pathD: string;
|
||||
if (isVertical) {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const midY = y1 + dy / 2;
|
||||
const r = (Math.abs(dx) < 1 || Math.abs(dy) < ELBOW_RADIUS * 2)
|
||||
? 0
|
||||
: Math.min(ELBOW_RADIUS, Math.abs(dx) / 2, Math.abs(dy) / 4);
|
||||
const sx = dx >= 0 ? 1 : -1;
|
||||
const sy = dy >= 0 ? 1 : -1;
|
||||
pathD = [
|
||||
`M ${x1},${y1}`,
|
||||
`V ${midY - sy * r}`,
|
||||
`Q ${x1},${midY} ${x1 + sx * r},${midY}`,
|
||||
`H ${x2 - sx * r}`,
|
||||
`Q ${x2},${midY} ${x2},${midY + sy * r}`,
|
||||
`V ${y2}`,
|
||||
].join(' ');
|
||||
} else {
|
||||
pathD = elbowPath(x1, y1, x2, y2);
|
||||
}
|
||||
const midX = x1 + (x2 - x1) / 2;
|
||||
const midY = y1 + (y2 - y1) / 2;
|
||||
const labelX = isVertical ? midX : midX + (x2 - midX) * 0.15;
|
||||
const labelY = isVertical ? midY + (y2 - midY) * 0.15 : y2;
|
||||
workflowTethers.push({
|
||||
key: `workflow-${wc.workflow_id}`,
|
||||
path: pathD,
|
||||
labelX,
|
||||
labelY,
|
||||
label: 'Make workflow',
|
||||
fading: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Sidecar tethers: workflow card to its sibling agent session (View Agent / Watch Live / Test Agent).
|
||||
for (const wc of Object.values(workflowCards)) {
|
||||
const openCard = workflowOpenCards[wc.workflow_id];
|
||||
if (!openCard?.sidecarSessionId || !openCard.sidecarKind) continue;
|
||||
const sidecarId = openCard.sidecarSessionId;
|
||||
const sidecar = cards[sidecarId];
|
||||
if (!sidecar) continue;
|
||||
let srcX = wc.x, srcY = wc.y;
|
||||
let dstX = sidecar.x, dstY = sidecar.y;
|
||||
if (liveDragInfo) {
|
||||
if (liveDragInfo.cardId === wc.workflow_id) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; }
|
||||
if (liveDragInfo.cardId === sidecarId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; }
|
||||
}
|
||||
const dstMeasured = measuredHeightsRef.current![sidecarId];
|
||||
const dstH = dstMeasured ?? (expandedSessionIds.includes(sidecarId)
|
||||
? Math.max(EXPANDED_CARD_MIN_H, sidecar.height)
|
||||
: sidecar.height);
|
||||
const wcH = wfHeight(wc);
|
||||
const workflowRect = { x: srcX, y: srcY, width: wc.width, height: wcH };
|
||||
const sidecarRect = { x: dstX, y: dstY, width: sidecar.width, height: dstH };
|
||||
const srcCenter = rectCenter(workflowRect);
|
||||
const dstCenter = rectCenter(sidecarRect);
|
||||
const a = borderPoint(workflowRect.x, workflowRect.y, workflowRect.width, workflowRect.height, dstCenter.x, dstCenter.y);
|
||||
const b = borderPoint(sidecarRect.x, sidecarRect.y, sidecarRect.width, sidecarRect.height, srcCenter.x, srcCenter.y);
|
||||
const x1 = a.x, y1 = a.y;
|
||||
const x2 = b.x, y2 = b.y;
|
||||
const pathD = elbowPath(x1, y1, x2, y2);
|
||||
const midX = x1 + (x2 - x1) / 2;
|
||||
const midY = y1 + (y2 - y1) / 2;
|
||||
const sidecarLabel = openCard.sidecarKind === 'testing' ? 'Testing' : 'Watching';
|
||||
workflowTethers.push({
|
||||
key: `sidecar-${wc.workflow_id}`,
|
||||
path: pathD,
|
||||
labelX: midX,
|
||||
labelY: midY,
|
||||
label: sidecarLabel,
|
||||
fading: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Run Monitor tether: the Workflows window to its spawned live-run card.
|
||||
// Same border-anchor + elbow math as the sidecar "Watching" arrow.
|
||||
const monitorTethers: Tether[] = [];
|
||||
if (workflowsHub && workflowsMonitorCard) {
|
||||
let hubX = workflowsHub.x, hubY = workflowsHub.y;
|
||||
let monX = workflowsMonitorCard.x, monY = workflowsMonitorCard.y;
|
||||
// Track live drag so the line follows the card in real time instead of
|
||||
// snapping into place on drop (same mechanism as the agent->browser tether).
|
||||
if (liveDragInfo) {
|
||||
if (liveDragInfo.cardId === 'workflows-hub') { hubX += liveDragInfo.dx; hubY += liveDragInfo.dy; }
|
||||
if (liveDragInfo.cardId === 'workflows-monitor') { monX += liveDragInfo.dx; monY += liveDragInfo.dy; }
|
||||
}
|
||||
const hubRect = { x: hubX, y: hubY, width: workflowsHub.width, height: workflowsHub.height };
|
||||
const monRect = { x: monX, y: monY, width: workflowsMonitorCard.width, height: workflowsMonitorCard.height };
|
||||
const hubC = rectCenter(hubRect);
|
||||
const monC = rectCenter(monRect);
|
||||
const a = borderPoint(hubRect.x, hubRect.y, hubRect.width, hubRect.height, monC.x, monC.y);
|
||||
const b = borderPoint(monRect.x, monRect.y, monRect.width, monRect.height, hubC.x, hubC.y);
|
||||
const midX = a.x + (b.x - a.x) / 2;
|
||||
const midY = a.y + (b.y - a.y) / 2;
|
||||
// The label box is left-anchored at labelX (rect starts there and grows
|
||||
// right), so shift left by half the text width to truly center it on the line.
|
||||
monitorTethers.push({
|
||||
key: 'workflows-monitor',
|
||||
path: elbowPath(a.x, a.y, b.x, b.y),
|
||||
labelX: midX - (workflowsMonitorLabel.length * 7.5) / 2,
|
||||
labelY: midY,
|
||||
label: workflowsMonitorLabel,
|
||||
fading: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Index outputs by their owning session so the per-session lookup below
|
||||
// doesn't scan the whole outputs map for every view-builder chat.
|
||||
const outputsBySession = new Map<string, string[]>();
|
||||
@@ -278,9 +472,9 @@ export function useTethers({
|
||||
}
|
||||
}
|
||||
|
||||
return [...agentTethers, ...browserTethers, ...viewTethers];
|
||||
return [...agentTethers, ...browserTethers, ...workflowTethers, ...viewTethers, ...monitorTethers];
|
||||
// measuredHeightsTick re-runs the memo once ResizeObserver reports a new
|
||||
// height after a collapse (the ref read is invisible to the dep checker).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, viewCards, outputs, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]);
|
||||
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, viewCards, outputs, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList, workflowsHub, workflowsMonitorCard, workflowsMonitorLabel]);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ export function getCardRect(id: string, type: CardType):
|
||||
const n = layoutState.notes[id];
|
||||
if (!n) return undefined;
|
||||
return { x: n.x, y: n.y, width: n.width, height: n.height };
|
||||
} else if (type === 'workflow') {
|
||||
const wc = layoutState.workflowCards[id];
|
||||
if (!wc) return undefined;
|
||||
return { x: wc.x, y: wc.y, width: wc.width, height: wc.height };
|
||||
} else if (type === 'workflows-hub') {
|
||||
const hub = layoutState.workflowsHub;
|
||||
if (!hub) return undefined;
|
||||
return { x: hub.x, y: hub.y, width: hub.width, height: hub.height };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { report } from '@/shared/serviceClient';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { expandSession } from '@/shared/state/agentsSlice';
|
||||
import { bringToFront } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardType } from '../state/useDashboardSelection';
|
||||
import type { CanvasActions } from './useCanvasControls';
|
||||
|
||||
@@ -13,6 +13,7 @@ interface UseArrowNavArgs {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
zoom: number;
|
||||
isActive: boolean;
|
||||
focusedCardId: string | null;
|
||||
@@ -25,6 +26,7 @@ export function useArrowNav({
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
zoom,
|
||||
isActive,
|
||||
focusedCardId,
|
||||
@@ -48,6 +50,9 @@ export function useArrowNav({
|
||||
for (const bc of Object.values(browserCards)) {
|
||||
allCardEntries.push({ id: bc.browser_id, type: 'browser', cx: bc.x + bc.width / 2, cy: bc.y + bc.height / 2 });
|
||||
}
|
||||
for (const wc of Object.values(workflowCards)) {
|
||||
allCardEntries.push({ id: wc.workflow_id, type: 'workflow', cx: wc.x + wc.width / 2, cy: wc.y + wc.height / 2 });
|
||||
}
|
||||
|
||||
const current = allCardEntries.find((c) => c.id === currentId);
|
||||
if (!current) return null;
|
||||
@@ -80,7 +85,7 @@ export function useArrowNav({
|
||||
}
|
||||
|
||||
return best ? { id: best.id, type: best.type } : null;
|
||||
}, [cards, viewCards, browserCards]);
|
||||
}, [cards, viewCards, browserCards, workflowCards]);
|
||||
|
||||
// Compute which directions have neighbors from the focused card
|
||||
const neighborDirections = useMemo(() => {
|
||||
|
||||
@@ -52,6 +52,12 @@ export function useDashboardInteractions({
|
||||
selection.selectCard(id, type, false);
|
||||
dispatch(bringToFront({ id, type }));
|
||||
|
||||
// The Workflows window is an app you click around inside, not a card you
|
||||
// re-center every tap. Single-click only raises + selects it; double-click
|
||||
// still zoom-to-fits (handleCardDoubleClick). Without this, clicking any
|
||||
// button inside it yanked the canvas into a re-zoom.
|
||||
if (type === 'workflows-hub' || type === 'workflows-monitor') return;
|
||||
|
||||
const alreadyExpanded = type === 'agent' && expandedSessionIds.includes(id);
|
||||
|
||||
if (alreadyExpanded) {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||
import { report } from '@/shared/serviceClient';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { closeSession, toggleExpandSession } from '@/shared/state/agentsSlice';
|
||||
import { removeViewCard, removeNote } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { removeViewCard, removeNote, removeWorkflowCard, closeWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { closeWorkflowCard } from '@/shared/state/workflowsSlice';
|
||||
import { removeBrowserCardCleanly } from '@/shared/browserTeardown';
|
||||
import type { useDashboardSelection } from '../state/useDashboardSelection';
|
||||
|
||||
@@ -80,6 +81,11 @@ export function useDashboardShortcuts({
|
||||
removeBrowserCardCleanly(id, dispatch);
|
||||
} else if (type === 'note') {
|
||||
dispatch(removeNote(id));
|
||||
} else if (type === 'workflow') {
|
||||
dispatch(removeWorkflowCard(id));
|
||||
dispatch(closeWorkflowCard(id));
|
||||
} else if (type === 'workflows-hub') {
|
||||
dispatch(closeWorkflowsHub());
|
||||
}
|
||||
}
|
||||
selection.deselectAll();
|
||||
|
||||
@@ -123,7 +123,10 @@ export function useDashboardCardActions({
|
||||
dispatch(tidyLayout({ expandedSessionIds: currentExpanded }));
|
||||
|
||||
const expandedSet = new Set(currentExpanded);
|
||||
const { cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers } = store.getState().dashboardLayout;
|
||||
const {
|
||||
cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers,
|
||||
workflowCards: tidiedWorkflows, workflowsHub: tidiedHub,
|
||||
} = store.getState().dashboardLayout;
|
||||
const allRects = [
|
||||
...Object.values(tidied).map((c) => ({
|
||||
x: c.x, y: c.y, width: c.width,
|
||||
@@ -131,6 +134,8 @@ export function useDashboardCardActions({
|
||||
})),
|
||||
...Object.values(tidiedViews).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
...Object.values(tidiedBrowsers).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
...Object.values(tidiedWorkflows).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
...(tidiedHub ? [{ x: tidiedHub.x, y: tidiedHub.y, width: tidiedHub.width, height: tidiedHub.height }] : []),
|
||||
];
|
||||
canvasActions.fitToCards(allRects);
|
||||
}, [dispatch, canvasActions]);
|
||||
|
||||
@@ -16,16 +16,24 @@ import {
|
||||
resetLayout,
|
||||
removeViewCard,
|
||||
clearPendingFocusBrowserId,
|
||||
clearPendingFocusWorkflowId,
|
||||
clearPendingFocusWorkflowsHub,
|
||||
type ViewCardPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { fetchOutputs, type Output } from '@/shared/state/outputsSlice';
|
||||
import { generateDashboardName } from '@/shared/state/dashboardsSlice';
|
||||
import { fetchWorkflows, fetchAllRuns, fetchActiveRuns } from '@/shared/state/workflowsSlice';
|
||||
import { fetchMissedRuns } from '@/shared/state/missedRunsSlice';
|
||||
import { dashboardWs } from '@/shared/ws/WebSocketManager';
|
||||
import { initBrowserCommandHandler } from '@/shared/browserCommandHandler';
|
||||
import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import type { CanvasActions } from '../interaction/useCanvasControls';
|
||||
|
||||
// Module-level so the missed-runs review pops exactly once per app launch,
|
||||
// not again on every dashboard switch.
|
||||
let missedRunsCheckedThisSession = false;
|
||||
|
||||
interface UseDashboardLifecycleArgs {
|
||||
isActive: boolean;
|
||||
dashboardId: string;
|
||||
@@ -66,6 +74,17 @@ export function useDashboardLifecycle({
|
||||
const pendingBrowserUrl = useAppSelector((state) => state.tempState.pendingBrowserUrl);
|
||||
const pendingFocusAgentId = useAppSelector((state) => state.tempState.pendingFocusAgentId);
|
||||
const pendingFocusBrowserId = useAppSelector((state) => state.dashboardLayout.pendingFocusBrowserId);
|
||||
const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId);
|
||||
const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub);
|
||||
|
||||
// Once per app launch: if scheduled fires elapsed while we were closed, fetch
|
||||
// them. The slice flips its toast flag on fulfilled, so a bottom-left nudge
|
||||
// shows instead of a card popping unrequested; the user opens the card from it.
|
||||
useEffect(() => {
|
||||
if (!isActive || missedRunsCheckedThisSession) return;
|
||||
missedRunsCheckedThisSession = true;
|
||||
dispatch(fetchMissedRuns());
|
||||
}, [isActive, dispatch]);
|
||||
|
||||
// Track dashboard engagement time
|
||||
useEffect(() => {
|
||||
@@ -96,6 +115,12 @@ export function useDashboardLifecycle({
|
||||
const unsubReconnect = dashboardWs.on('dashboard:reconnected', () => {
|
||||
dispatch(fetchSessions({ dashboardId }));
|
||||
dispatch(fetchLayout({ dashboardId, isReconnect: true }));
|
||||
// workflow:run/updated/deleted are global broadcasts that skip the replay
|
||||
// log, so a socket gap drops them: refetch to heal stale "running" cards,
|
||||
// ghost workflows, and missed run history on reconnect.
|
||||
dispatch(fetchWorkflows(dashboardId));
|
||||
dispatch(fetchAllRuns(200));
|
||||
dispatch(fetchActiveRuns());
|
||||
});
|
||||
// DEFERRABLE: history list (for the search palette) and outputs
|
||||
// (for the apps panel) aren't on the first-paint path. Same for the
|
||||
@@ -110,6 +135,7 @@ export function useDashboardLifecycle({
|
||||
dispatch(fetchOutputs()).then((res) => {
|
||||
if (fetchOutputs.fulfilled.match(res)) setOutputsRefetched(true);
|
||||
});
|
||||
dispatch(fetchWorkflows(dashboardId));
|
||||
dashboardWs.connect();
|
||||
};
|
||||
const idleHandle = (typeof window !== 'undefined' && (window as any).requestIdleCallback)
|
||||
@@ -230,6 +256,44 @@ export function useDashboardLifecycle({
|
||||
}, 200);
|
||||
}, [isActive, pendingFocusBrowserId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]);
|
||||
|
||||
// Same pan/highlight choreography for newly-spawned workflow cards.
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
if (!pendingFocusWorkflowId || !layoutInitialized) return;
|
||||
const workflowId = pendingFocusWorkflowId;
|
||||
dispatch(clearPendingFocusWorkflowId());
|
||||
setTimeout(() => {
|
||||
const card = store.getState().dashboardLayout.workflowCards[workflowId];
|
||||
if (card) {
|
||||
canvasActions.fitToCards(
|
||||
[{ x: card.x, y: card.y, width: card.width, height: card.height }],
|
||||
1.15,
|
||||
true,
|
||||
);
|
||||
handleHighlightCard(workflowId);
|
||||
}
|
||||
}, 200);
|
||||
}, [isActive, pendingFocusWorkflowId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]);
|
||||
|
||||
// Pan/zoom to Workflows Hub on Expand; chained rAFs ensure fit runs after the hub div lands at its new coords.
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
if (!pendingFocusWorkflowsHub || !layoutInitialized) return;
|
||||
dispatch(clearPendingFocusWorkflowsHub());
|
||||
const fit = () => {
|
||||
const hub = store.getState().dashboardLayout.workflowsHub;
|
||||
if (!hub) return;
|
||||
canvasActions.fitToCards(
|
||||
[{ x: hub.x, y: hub.y, width: hub.width, height: hub.height }],
|
||||
1.1,
|
||||
true,
|
||||
);
|
||||
};
|
||||
requestAnimationFrame(() => requestAnimationFrame(fit));
|
||||
const fallback = setTimeout(fit, 300);
|
||||
return () => clearTimeout(fallback);
|
||||
}, [isActive, pendingFocusWorkflowsHub, layoutInitialized, dispatch, canvasActions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutInitialized || restoredExpandedRef.current) return;
|
||||
restoredExpandedRef.current = true;
|
||||
|
||||
@@ -17,6 +17,7 @@ interface UseSubAgentLifecycleArgs {
|
||||
isActive: boolean;
|
||||
sessions: Record<string, AgentSession>;
|
||||
cards: Record<string, CardPosition>;
|
||||
workflowOpenCards: Record<string, { sidecarSessionId?: string | null; sidecarKind?: string | null }>;
|
||||
layoutInitialized: boolean;
|
||||
autoRevealSubAgents: boolean;
|
||||
expandedSessionIds: string[];
|
||||
@@ -26,6 +27,7 @@ export function useSubAgentLifecycle({
|
||||
isActive,
|
||||
sessions,
|
||||
cards,
|
||||
workflowOpenCards,
|
||||
layoutInitialized,
|
||||
autoRevealSubAgents,
|
||||
expandedSessionIds,
|
||||
@@ -43,6 +45,11 @@ export function useSubAgentLifecycle({
|
||||
const subSessions = Object.values(sessions).filter(
|
||||
(s) => (s.mode === 'sub-agent' || s.mode === 'invoked-agent') && s.parent_session_id,
|
||||
);
|
||||
const workflowSidecarSessionIds = new Set(
|
||||
Object.values(workflowOpenCards || {})
|
||||
.map((card) => card.sidecarSessionId || null)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
|
||||
// 1) Auto-reveal newly spawned sub-agents (skip already-terminal ones on load)
|
||||
for (const sub of subSessions) {
|
||||
@@ -103,6 +110,7 @@ export function useSubAgentLifecycle({
|
||||
// 2) Auto-collapse sub-agents when they complete
|
||||
const TERMINAL = new Set(['completed', 'error', 'stopped']);
|
||||
for (const sub of subSessions) {
|
||||
if (workflowSidecarSessionIds.has(sub.id)) continue;
|
||||
const prev = prevSubStatusRef.current[sub.id];
|
||||
if (prev !== sub.status && TERMINAL.has(sub.status) && cards[sub.id]) {
|
||||
dispatch(collapseSession(sub.id));
|
||||
@@ -121,6 +129,7 @@ export function useSubAgentLifecycle({
|
||||
if (prev !== parent.status && TERMINAL.has(parent.status)) {
|
||||
const children = subSessions.filter((s) => s.parent_session_id === pid);
|
||||
for (const child of children) {
|
||||
if (workflowSidecarSessionIds.has(child.id)) continue;
|
||||
if (!cards[child.id]) continue;
|
||||
dispatch(collapseSession(child.id));
|
||||
dispatch(removeCard(child.id));
|
||||
@@ -136,5 +145,5 @@ export function useSubAgentLifecycle({
|
||||
if (parent) newParentStatuses[pid] = parent.status;
|
||||
}
|
||||
prevParentStatusRef.current = newParentStatuses;
|
||||
}, [isActive, sessions, cards, layoutInitialized, autoRevealSubAgents, dispatch]);
|
||||
}, [isActive, sessions, cards, workflowOpenCards, layoutInitialized, autoRevealSubAgents, dispatch]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useElementSelection } from '@/app/components/editor/ElementSelectionContext';
|
||||
import { useCanvasControls } from '../interaction/useCanvasControls';
|
||||
@@ -31,6 +32,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
const isElementSelectMode = elementSelectionCtx?.selectMode ?? false;
|
||||
const {
|
||||
dashboardName, sessions, expandedSessionIds, cards, viewCards, browserCards,
|
||||
workflowCards, workflowItems, workflowOpenCards, workflowsHub,
|
||||
pendingFocusWorkflowId, pendingFocusWorkflowsHub,
|
||||
notes, pendingFocusNoteId, layoutInitialized, persistedExpandedSessionIds,
|
||||
zoomSensitivity, newAgentShortcut, browserHomepage, expandNewChats,
|
||||
autoRevealSubAgents, outputs, outputsLoaded, glowingAgentCards, glowingBrowserCards,
|
||||
@@ -40,9 +43,21 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
// ref when one of its values changes, so this is the right granularity).
|
||||
const sessionList = useMemo(() => Object.values(sessions), [sessions]);
|
||||
|
||||
// Run Monitor card geometry + its tether label ("Watching" live, "Viewing" done).
|
||||
// Only "active" while its workflow still exists; otherwise the card is gone and
|
||||
// the tether must not dangle (e.g. the workflow was trashed while watching).
|
||||
const workflowsMonitorIdRaw = useAppSelector((s) => s.dashboardLayout.workflowsMonitorId);
|
||||
const monitorActive = !!workflowsMonitorIdRaw && !!workflowItems[workflowsMonitorIdRaw];
|
||||
const workflowsMonitorId = monitorActive ? workflowsMonitorIdRaw : null;
|
||||
const workflowsMonitorCard = useAppSelector((s) =>
|
||||
(monitorActive ? s.dashboardLayout.workflowsMonitorCard : null));
|
||||
const monitorIsLive = useAppSelector((s) =>
|
||||
!!workflowsMonitorId && s.workflows.active.some((a) => a.workflow_id === workflowsMonitorId));
|
||||
const workflowsMonitorLabel = monitorIsLive ? 'Watching' : 'Viewing';
|
||||
|
||||
const contentBounds = useMemo(
|
||||
() => computeContentBounds(cards, viewCards, browserCards),
|
||||
[cards, viewCards, browserCards],
|
||||
() => computeContentBounds(cards, viewCards, browserCards, workflowCards, workflowsHub),
|
||||
[cards, viewCards, browserCards, workflowCards, workflowsHub],
|
||||
);
|
||||
|
||||
const canvas = useCanvasControls(zoomSensitivity, contentBounds, isActive);
|
||||
@@ -52,6 +67,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
viewCards,
|
||||
browserCards,
|
||||
notes,
|
||||
workflowCards,
|
||||
workflowsHub,
|
||||
);
|
||||
const {
|
||||
toolbarRef, toolbarOpen, setToolbarOpen, searchPaletteOpen, setSearchPaletteOpen,
|
||||
@@ -146,6 +163,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
isActive,
|
||||
sessions,
|
||||
cards,
|
||||
workflowOpenCards,
|
||||
layoutInitialized,
|
||||
autoRevealSubAgents,
|
||||
expandedSessionIds,
|
||||
@@ -158,6 +176,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
workflowsHub,
|
||||
notes,
|
||||
expandedSessionIds,
|
||||
captureNow,
|
||||
@@ -206,6 +226,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
zoom: canvas.zoom,
|
||||
isActive,
|
||||
focusedCardId,
|
||||
@@ -271,6 +292,9 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
glowingBrowserCards,
|
||||
cards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
workflowItems,
|
||||
workflowOpenCards,
|
||||
viewCards,
|
||||
outputs,
|
||||
expandedSessionIds,
|
||||
@@ -278,11 +302,15 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
measuredHeightsRef,
|
||||
measuredHeightsTick,
|
||||
sessionList,
|
||||
workflowsHub,
|
||||
workflowsMonitorCard,
|
||||
workflowsMonitorLabel,
|
||||
});
|
||||
|
||||
return {
|
||||
c, dashboardId, dashboardName, canvas, selection, sessions, sessionList,
|
||||
cards, viewCards, browserCards, notes, outputs, glowingAgentCards,
|
||||
workflowCards, workflowsHub,
|
||||
expandedSessionIds, tethers, highlightedCardId, autoFocusSessionId,
|
||||
focusedCardId, pendingFocusNoteId, multiDragDelta, shakeDirection,
|
||||
neighborDirections, toolbarOpen, searchPaletteOpen, newAgentBounce,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useRef, useEffect, RefObject } from 'react';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
export type { CardType } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardType } from '@/shared/state/dashboardLayoutSlice';
|
||||
@@ -43,6 +43,8 @@ export function useDashboardSelection(
|
||||
viewCards: Record<string, ViewCardPosition>,
|
||||
browserCards: Record<string, BrowserCardPosition> = {},
|
||||
notes: Record<string, NotePosition> = {},
|
||||
workflowCards: Record<string, WorkflowCardPosition> = {},
|
||||
workflowsHub: WorkflowsHubPosition | null = null,
|
||||
) {
|
||||
const [selectedIds, setSelectedIds] = useState<Map<string, CardType>>(new Map());
|
||||
const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
|
||||
@@ -77,8 +79,10 @@ export function useDashboardSelection(
|
||||
for (const vc of Object.values(viewCards)) next.set(vc.output_id, 'view');
|
||||
for (const bc of Object.values(browserCards)) next.set(bc.browser_id, 'browser');
|
||||
for (const n of Object.values(notes)) next.set(n.note_id, 'note');
|
||||
for (const wc of Object.values(workflowCards)) next.set(wc.workflow_id, 'workflow');
|
||||
if (workflowsHub) next.set('workflows-hub', 'workflows-hub');
|
||||
setSelectedIds(next);
|
||||
}, [cards, viewCards, browserCards, notes]);
|
||||
}, [cards, viewCards, browserCards, notes, workflowCards, workflowsHub]);
|
||||
|
||||
const selectCard = useCallback(
|
||||
(id: string, type: CardType, shiftKey: boolean) => {
|
||||
@@ -161,6 +165,31 @@ export function useDashboardSelection(
|
||||
}
|
||||
}
|
||||
|
||||
for (const wc of Object.values(workflowCards)) {
|
||||
if (
|
||||
rectsIntersect(rect, {
|
||||
x: wc.x,
|
||||
y: wc.y,
|
||||
width: wc.width,
|
||||
height: wc.height,
|
||||
})
|
||||
) {
|
||||
intersecting.set(wc.workflow_id, 'workflow');
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
workflowsHub &&
|
||||
rectsIntersect(rect, {
|
||||
x: workflowsHub.x,
|
||||
y: workflowsHub.y,
|
||||
width: workflowsHub.width,
|
||||
height: workflowsHub.height,
|
||||
})
|
||||
) {
|
||||
intersecting.set('workflows-hub', 'workflows-hub');
|
||||
}
|
||||
|
||||
if (shiftKey) {
|
||||
const base = selectionBeforeMarqueeRef.current;
|
||||
const next = new Map(base);
|
||||
@@ -176,7 +205,7 @@ export function useDashboardSelection(
|
||||
|
||||
return intersecting;
|
||||
},
|
||||
[cards, viewCards, browserCards, notes],
|
||||
[cards, viewCards, browserCards, notes, workflowCards, workflowsHub],
|
||||
);
|
||||
|
||||
const handleCanvasMouseDown = useCallback(
|
||||
|
||||
@@ -24,6 +24,12 @@ export function useDashboardSelectors(dashboardId: string) {
|
||||
}
|
||||
return out;
|
||||
}, [allBrowserCards, dashboardId]);
|
||||
const workflowCards = useAppSelector((state) => state.dashboardLayout.workflowCards);
|
||||
const workflowsHub = useAppSelector((state) => state.dashboardLayout.workflowsHub);
|
||||
const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId);
|
||||
const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub);
|
||||
const workflowItems = useAppSelector((state) => state.workflows.items);
|
||||
const workflowOpenCards = useAppSelector((state) => state.workflows.openCards);
|
||||
const notes = useAppSelector((state) => state.dashboardLayout.notes);
|
||||
const pendingFocusNoteId = useAppSelector((state) => state.dashboardLayout.pendingFocusNoteId);
|
||||
const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized);
|
||||
@@ -45,6 +51,12 @@ export function useDashboardSelectors(dashboardId: string) {
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
workflowItems,
|
||||
workflowOpenCards,
|
||||
workflowsHub,
|
||||
pendingFocusWorkflowId,
|
||||
pendingFocusWorkflowsHub,
|
||||
notes,
|
||||
pendingFocusNoteId,
|
||||
layoutInitialized,
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
type ViewCardPosition,
|
||||
type BrowserCardPosition,
|
||||
type NotePosition,
|
||||
type WorkflowCardPosition,
|
||||
type WorkflowsHubPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
interface UseLayoutSaveArgs {
|
||||
@@ -15,6 +17,8 @@ interface UseLayoutSaveArgs {
|
||||
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[];
|
||||
captureNow: () => void;
|
||||
@@ -31,6 +35,8 @@ export function useLayoutSave({
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
workflowsHub,
|
||||
notes,
|
||||
expandedSessionIds,
|
||||
captureNow,
|
||||
@@ -47,7 +53,7 @@ export function useLayoutSave({
|
||||
skipInitialSave.current = false;
|
||||
return;
|
||||
}
|
||||
const payload = { dashboardId, cards, viewCards, browserCards, notes, expandedSessionIds };
|
||||
const payload = { dashboardId, cards, viewCards, browserCards, workflowCards, workflowsHub, notes, expandedSessionIds };
|
||||
pendingSaveRef.current = payload;
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
@@ -56,7 +62,7 @@ export function useLayoutSave({
|
||||
saveTimerRef.current = null;
|
||||
captureNow();
|
||||
}, 500);
|
||||
}, [isActive, cards, viewCards, browserCards, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]);
|
||||
}, [isActive, cards, viewCards, browserCards, workflowCards, workflowsHub, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Bottom-left nudge shown on launch when scheduled runs elapsed while the app
|
||||
// was closed. It stays put until the user acts (no auto-hide): Review opens the
|
||||
// Workflows app (its Home surfaces the missed runs); the X dismisses it.
|
||||
|
||||
import React from 'react';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { hideMissedRunsToast } from '@/shared/state/missedRunsSlice';
|
||||
import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
export default function MissedRunsToast() {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const open = useAppSelector((s) => s.missedRuns.toastOpen);
|
||||
const count = useAppSelector((s) => s.missedRuns.items.length);
|
||||
|
||||
const onReview = React.useCallback(() => {
|
||||
dispatch(openWorkflowsApp());
|
||||
dispatch(hideMissedRunsToast());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={open && count > 0}
|
||||
autoHideDuration={null}
|
||||
onClose={() => dispatch(hideMissedRunsToast())}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<Alert
|
||||
icon={false}
|
||||
severity="info"
|
||||
sx={{
|
||||
bgcolor: c.bg.surface,
|
||||
color: c.text.primary,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
'& .MuiAlert-action': { alignItems: 'center', pt: 0 },
|
||||
}}
|
||||
action={
|
||||
<>
|
||||
<Button size="small" onClick={onReview} sx={{ color: c.accent.primary, fontWeight: 700 }}>
|
||||
Review
|
||||
</Button>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => dispatch(hideMissedRunsToast())}
|
||||
sx={{ color: c.text.muted, ml: 0.25, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{`${count} scheduled run${count === 1 ? '' : 's'} ${count === 1 ? 'was' : 'were'} missed while you were away`}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import type { Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { runWorkflowNow, deleteWorkflow, updateWorkflow, openWorkflowCard } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, formatTime, formatHourLabel, stepsSignature } from './scheduleUtils';
|
||||
import { useWindowedList } from '@/shared/hooks/useWindowedList';
|
||||
|
||||
interface Props {
|
||||
view: 'Week' | 'Month' | 'List';
|
||||
density: 'compact' | 'roomy';
|
||||
onSelectWorkflow?: (id: string, fireAt?: Date) => void;
|
||||
refDate?: Date;
|
||||
}
|
||||
|
||||
// Both compact (popover) and roomy (hub) show the full 24 hours scrollable —
|
||||
// the user explicitly wants midnight visible at the top, not "9am" as the
|
||||
// starting hour. The scroll container caps the visible window.
|
||||
const HOURS_24 = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
// At/above this many list rows (day headers + event rows), window the list so
|
||||
// only near-viewport rows stay mounted. Below it, render whole; spacers aren't
|
||||
// worth the churn on a short list.
|
||||
const LIST_WINDOW_MIN_ROWS = 60;
|
||||
|
||||
interface CalendarEvent {
|
||||
workflow_id: string;
|
||||
fire_at: string;
|
||||
}
|
||||
|
||||
// One flattened list row. Windowing unmounts at this granularity, so a dense
|
||||
// single day no longer mounts all ~96 of its rows just for being near the
|
||||
// viewport: only the rows actually in view (plus buffer) stay in the DOM.
|
||||
type ListRow =
|
||||
| { kind: 'header'; id: string; date: Date; isToday: boolean }
|
||||
| { kind: 'event'; id: string; ev: { workflow: Workflow; date: Date } }
|
||||
| { kind: 'empty'; id: string };
|
||||
|
||||
export default function ScheduleCalendar({ view, density, onSelectWorkflow, refDate }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const workflows = useAppSelector((s) => Object.values(s.workflows.items));
|
||||
const allPaused = useAppSelector((s) => s.workflows.paused);
|
||||
// Live clock for the "now" line; a snapshot would drift and refDate may be
|
||||
// a navigated week, so it can't double as the current moment.
|
||||
const [now, setNow] = useState<Date>(() => new Date());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
// Right-click menu: pinned position + the workflow whose pill was
|
||||
// clicked. Same anchor pattern as MUI's menu examples.
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null);
|
||||
const closeMenu = () => setCtxMenu(null);
|
||||
const onRunNow = () => {
|
||||
if (!ctxMenu) return;
|
||||
dispatch(runWorkflowNow({
|
||||
id: ctxMenu.workflow.id,
|
||||
signature: stepsSignature(ctxMenu.workflow.steps),
|
||||
}));
|
||||
closeMenu();
|
||||
};
|
||||
const onPauseToggle = () => {
|
||||
if (!ctxMenu) return;
|
||||
const wf = ctxMenu.workflow;
|
||||
dispatch(updateWorkflow({
|
||||
id: wf.id,
|
||||
patch: { schedule: { ...wf.schedule, enabled: !wf.schedule.enabled } as any },
|
||||
ifMatch: wf.updated_at || null,
|
||||
}));
|
||||
closeMenu();
|
||||
};
|
||||
const onEdit = () => {
|
||||
if (!ctxMenu) return;
|
||||
dispatch(addWorkflowCard({ workflowId: ctxMenu.workflow.id }));
|
||||
// Right-click "Edit" on a calendar entry opens the new Edit Agent
|
||||
// chat view, matching the post-revamp design (Image #38).
|
||||
dispatch(openWorkflowCard({ workflowId: ctxMenu.workflow.id, view: 'edit_agent' }));
|
||||
closeMenu();
|
||||
};
|
||||
const onDelete = () => {
|
||||
if (!ctxMenu) return;
|
||||
const ok = window.confirm(`Delete "${ctxMenu.workflow.title}"? Scheduled runs will stop.`);
|
||||
if (!ok) { closeMenu(); return; }
|
||||
dispatch(deleteWorkflow(ctxMenu.workflow.id));
|
||||
closeMenu();
|
||||
};
|
||||
const ctxMenuEl = (
|
||||
<Menu
|
||||
open={Boolean(ctxMenu)}
|
||||
onClose={closeMenu}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={ctxMenu ? { top: ctxMenu.y, left: ctxMenu.x } : undefined}>
|
||||
<MenuItem onClick={onRunNow}>Run now</MenuItem>
|
||||
<MenuItem onClick={onPauseToggle}>{ctxMenu?.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'}</MenuItem>
|
||||
<MenuItem onClick={onEdit}>Edit…</MenuItem>
|
||||
<MenuItem onClick={onDelete} sx={{ color: c.status.error }}>Delete</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
// refDate is recreated on every render unless the caller memoizes it.
|
||||
// Pin the calendar to a day-precision key so occurrence fetches only
|
||||
// change when the visible day, view, or schedule set changes.
|
||||
const today = refDate || new Date();
|
||||
const dayKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
|
||||
const compact = density === 'compact';
|
||||
const range = view === 'Month' ? 35 : view === 'Week' ? 7 : 14;
|
||||
const rangeStart = useMemo(
|
||||
() => view === 'Month' ? startOfMonthGrid(today) : view === 'Week' ? startOfWeek(today) : new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[view, dayKey],
|
||||
);
|
||||
const rangeEndExclusive = useMemo(() => addDays(rangeStart, range), [rangeStart, range]);
|
||||
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
|
||||
const [calendarFetchKey, setCalendarFetchKey] = useState('');
|
||||
// Key off only the fields that change which occurrences exist. Deliberately
|
||||
// NOT updated_at: the scheduler bumps it every tick (recomputing next_run_at)
|
||||
// and pushes a workflow:updated over the socket, which would churn this key
|
||||
// and blank the calendar (the eventsByDay gate) until the next fetch lands.
|
||||
const workflowScheduleKey = workflows
|
||||
.map((w) => `${w.id}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.day_of_month ?? ''}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
const fromIso = rangeStart.toISOString();
|
||||
const toIso = rangeEndExclusive.toISOString();
|
||||
const calendarRequestKey = `${view}:${fromIso}:${toIso}:${workflowScheduleKey}`;
|
||||
// The visible window alone decides whether shown events are even plausible.
|
||||
// Gating on this (not the full request key) means a schedule edit refetches
|
||||
// without blanking the calendar first: we keep the current events until the
|
||||
// fresh ones land. Only a view/date change, where old events are for the
|
||||
// wrong window, clears them.
|
||||
const calendarWindowKey = `${view}:${fromIso}:${toIso}`;
|
||||
|
||||
useEffect(() => {
|
||||
// No AbortController: the global fetch interceptor (shared/config) dedupes
|
||||
// GETs by URL onto ONE underlying request, so aborting on cleanup (which
|
||||
// fires when this effect re-runs as workflows hydrate) rejects the shared
|
||||
// request and the re-fired fetch with it, leaving the calendar empty on
|
||||
// first load. The `cancelled` guard already stops stale state writes.
|
||||
let cancelled = false;
|
||||
fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`calendar failed ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setCalendarEvents((data.events || []) as CalendarEvent[]);
|
||||
setCalendarFetchKey(calendarWindowKey);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setCalendarFetchKey(calendarWindowKey);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fromIso, toIso, calendarRequestKey]);
|
||||
|
||||
const eventsByDay = useMemo(() => {
|
||||
const map = new Map<string, { workflow: Workflow; date: Date }[]>();
|
||||
if (calendarFetchKey !== calendarWindowKey) {
|
||||
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
|
||||
}
|
||||
const workflowById = new Map(workflows.map((wf) => [wf.id, wf]));
|
||||
for (const event of calendarEvents) {
|
||||
const wf = workflowById.get(event.workflow_id);
|
||||
if (!wf) continue;
|
||||
const d = new Date(event.fire_at);
|
||||
if (Number.isNaN(d.getTime())) continue;
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
const arr = map.get(key) || [];
|
||||
arr.push({ workflow: wf, date: d });
|
||||
map.set(key, arr);
|
||||
}
|
||||
for (const arr of map.values()) {
|
||||
arr.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
}
|
||||
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
|
||||
}, [calendarEvents, calendarFetchKey, calendarWindowKey, workflows, rangeStart, rangeEndExclusive]);
|
||||
|
||||
// List view can fan out to ~1300 rows for a dense schedule (every 15 min over
|
||||
// 14 days). Flatten days into rows and window at the row level so off-screen
|
||||
// rows unmount instead of weighing the whole app down. Computed up here (not
|
||||
// in the List branch) so the windowing hook runs before the Week/Month early
|
||||
// returns.
|
||||
const upcoming = useMemo(() => {
|
||||
const out: { date: Date; events: { workflow: Workflow; date: Date }[]; isToday: boolean }[] = [];
|
||||
for (let i = 0; i < 14; i += 1) {
|
||||
const day = addDays(today, i);
|
||||
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
|
||||
const arr = eventsByDay.map.get(key) || [];
|
||||
const isToday = sameDay(day, today);
|
||||
if (arr.length || isToday) out.push({ date: day, events: arr, isToday });
|
||||
}
|
||||
return out;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventsByDay, dayKey]);
|
||||
const rows = useMemo<ListRow[]>(() => {
|
||||
const out: ListRow[] = [];
|
||||
for (const day of upcoming) {
|
||||
const iso = day.date.toISOString();
|
||||
out.push({ kind: 'header', id: `h:${iso}`, date: day.date, isToday: day.isToday });
|
||||
if (day.events.length === 0) {
|
||||
out.push({ kind: 'empty', id: `x:${iso}` });
|
||||
} else {
|
||||
for (const ev of day.events) {
|
||||
out.push({ kind: 'event', id: `${iso}#${ev.workflow.id}#${ev.date.getTime()}`, ev });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [upcoming]);
|
||||
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
|
||||
const estimateRowHeight = useCallback((index: number) => {
|
||||
const r = rows[index];
|
||||
if (!r) return 41;
|
||||
return r.kind === 'header' ? 52 : r.kind === 'empty' ? 36 : 41;
|
||||
}, [rows]);
|
||||
const windowing = useWindowedList({
|
||||
ids: rowIds,
|
||||
estimateHeight: estimateRowHeight,
|
||||
enabled: view === 'List' && rows.length >= LIST_WINDOW_MIN_ROWS,
|
||||
});
|
||||
|
||||
const SLOT_H = compact ? 40 : 60;
|
||||
const ROW_LABEL = compact ? '0.7rem' : '0.74rem';
|
||||
const DAY_NUM = compact ? '0.95rem' : '1.15rem';
|
||||
const DAY_LABEL = compact ? '0.66rem' : '0.72rem';
|
||||
const EVENT_FS = compact ? '0.56rem' : '0.58rem';
|
||||
|
||||
if (view === 'Week') {
|
||||
const start = startOfWeek(today);
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
||||
const HOURS = HOURS_24;
|
||||
const nowColIdx = days.findIndex((d) => sameDay(d, now));
|
||||
const nowTopPx = (now.getHours() + now.getMinutes() / 60) * SLOT_H;
|
||||
// Prefer the short zone name ("PDT", "EST", "JST") so the label
|
||||
// reads in plain English instead of "GMT-7". formatToParts is wide-
|
||||
// supported; if it ever fails we degrade silently rather than show
|
||||
// a confusing fallback.
|
||||
const TZ_LABEL = (() => {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('en', { timeZoneName: 'short' }).formatToParts(new Date());
|
||||
return parts.find((p) => p.type === 'timeZoneName')?.value || '';
|
||||
} catch { return ''; }
|
||||
})();
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', color: c.text.secondary }}>
|
||||
{/* Day headers: muted weekday caps; today's date gets the filled circle */}
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '64px repeat(7, 1fr)',
|
||||
gap: 0,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
bgcolor: c.bg.surface,
|
||||
zIndex: 20,
|
||||
borderBottom: `1px solid ${c.border.subtle}`,
|
||||
pt: 1.25,
|
||||
pb: 0.5,
|
||||
overflow: 'hidden',
|
||||
isolation: 'isolate',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: '-1px',
|
||||
bgcolor: c.bg.surface,
|
||||
zIndex: 0,
|
||||
},
|
||||
'& > *': { position: 'relative', zIndex: 1 },
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'flex-end', pr: 1, pb: 0.5 }}>
|
||||
{!compact && (
|
||||
<Typography sx={{ fontSize: '0.62rem', color: c.text.ghost, fontWeight: 500 }}>{TZ_LABEL}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{days.map((d) => {
|
||||
const isToday = sameDay(d, now);
|
||||
return (
|
||||
<Box key={d.toISOString()} sx={{ textAlign: 'center', pb: 0.5 }}>
|
||||
<Typography sx={{ fontSize: DAY_LABEL, color: c.text.muted, fontWeight: 600, letterSpacing: '0.08em', lineHeight: 1.3, textTransform: 'uppercase' }}>
|
||||
{WEEKDAY_LABEL_SHORT[d.getDay()]}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, boxSizing: 'border-box', width: compact ? 26 : 32, height: compact ? 26 : 32, borderRadius: '50%', bgcolor: isToday ? c.accent.primary : 'transparent', color: isToday ? '#fff' : c.text.primary, fontWeight: isToday ? 600 : 500, fontSize: DAY_NUM, lineHeight: 1, mt: 0.25, boxShadow: isToday ? `0 0 0 1.5px ${c.bg.surface}, 0 0 0 3px ${c.accent.primary}` : 'none' }}>{d.getDate()}</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', position: 'relative', zIndex: 0 }}>
|
||||
{HOURS.map((hour, hourIdx) => (
|
||||
<React.Fragment key={hour}>
|
||||
{/* Hour label sits inside its row (top-aligned) rather than
|
||||
straddling the line above it; that way the first row
|
||||
doesn't clip "12 AM" and the labels never drift when the
|
||||
body scrolls. Apple Calendar does the same. */}
|
||||
<Box sx={{
|
||||
height: SLOT_H, fontSize: ROW_LABEL,
|
||||
color: c.text.ghost, fontWeight: 500,
|
||||
textAlign: 'right', pr: 1, pt: 0.25,
|
||||
borderTop: hourIdx === 0 ? 'none' : `1px solid ${c.border.subtle}`,
|
||||
}}>
|
||||
{formatHourLabel(hour)}
|
||||
</Box>
|
||||
{days.map((d) => {
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
const evs = (eventsByDay.map.get(key) || []).filter((e) => e.date.getHours() === hour);
|
||||
const targetWeekday = d.getDay();
|
||||
return (
|
||||
<Box
|
||||
key={`${d.toISOString()}-${hour}`}
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const wid = e.dataTransfer.getData('application/x-workflow-id');
|
||||
if (!wid) return;
|
||||
const wf = workflows.find((w) => w.id === wid);
|
||||
if (!wf) return;
|
||||
// Build the patched schedule: new hour, and for
|
||||
// weekly schedules swap on_days to just the target
|
||||
// weekday. Daily/monthly only get the new hour.
|
||||
const sched = { ...wf.schedule, hour } as typeof wf.schedule;
|
||||
if (sched.repeat_unit === 'week') sched.on_days = [targetWeekday];
|
||||
dispatch(updateWorkflow({
|
||||
id: wf.id,
|
||||
patch: { schedule: sched as any },
|
||||
ifMatch: wf.updated_at || null,
|
||||
}));
|
||||
}}
|
||||
sx={{ height: SLOT_H, borderLeft: `1px solid ${c.border.subtle}`, borderTop: hourIdx === 0 ? 'none' : `1px solid ${c.border.subtle}`, position: 'relative', overflow: 'hidden' }}>
|
||||
<EventStack
|
||||
events={evs}
|
||||
paused={allPaused}
|
||||
now={now}
|
||||
maxVisible={compact ? 1 : 3}
|
||||
onSelectWorkflow={onSelectWorkflow}
|
||||
eventFontSize={EVENT_FS}
|
||||
onContextWorkflow={(wf, ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: wf }); }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{nowColIdx >= 0 && (
|
||||
<Box sx={{
|
||||
position: 'absolute', pointerEvents: 'none', zIndex: 3,
|
||||
top: `${nowTopPx}px`,
|
||||
left: `calc(64px + ${nowColIdx} * ((100% - 64px) / 7))`,
|
||||
width: 'calc((100% - 64px) / 7)',
|
||||
height: 0, borderTop: `2px solid ${c.status.error}`,
|
||||
}}>
|
||||
<Box sx={{ position: 'absolute', left: -3, top: -4, width: 7, height: 7, borderRadius: '50%', bgcolor: c.status.error }} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{ctxMenuEl}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === 'Month') {
|
||||
const start = startOfMonthGrid(today);
|
||||
const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i));
|
||||
const accent = c.accent.primary;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}>
|
||||
{/* Sticky weekday header so it stays visible even when the
|
||||
calendar body scrolls. Slightly bigger + tinted bg so it
|
||||
reads cleanly in both light and dark themes. */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', flexShrink: 0, position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, borderBottom: `1px solid ${c.border.subtle}`, pt: 1.25, pb: 0.6 }}>
|
||||
{WEEKDAY_LABEL_SHORT.map((l, i) => (
|
||||
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: '0.74rem', color: c.text.muted, fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' }}>{l}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gridTemplateRows: `repeat(5, minmax(${compact ? 70 : 96}px, 1fr))`, flex: 1, minHeight: 0, gap: 0, borderLeft: `1px solid ${c.border.subtle}` }}>
|
||||
{cells.map((d) => {
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
const evs = eventsByDay.map.get(key) || [];
|
||||
const isToday = sameDay(d, now);
|
||||
const inMonth = d.getMonth() === today.getMonth();
|
||||
return (
|
||||
<Box key={d.toISOString()} sx={{ borderRight: `1px solid ${c.border.subtle}`, borderBottom: `1px solid ${c.border.subtle}`, p: 0.5, position: 'relative', overflow: 'hidden', bgcolor: inMonth ? 'transparent' : c.bg.elevated }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
|
||||
{/* Out-of-month dates still need to be legible (Apple
|
||||
Calendar shows them in a muted shade, not invisible).
|
||||
Color tweak instead of opacity so dark themes stay
|
||||
readable. */}
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, boxSizing: 'border-box', width: 22, height: 22, borderRadius: '50%', bgcolor: isToday ? accent : 'transparent', color: isToday ? '#fff' : inMonth ? c.text.primary : c.text.ghost, fontWeight: isToday ? 600 : 500, fontSize: '0.82rem', lineHeight: 1, boxShadow: isToday ? `0 0 0 1.5px ${c.bg.surface}, 0 0 0 3px ${accent}` : 'none' }}>{d.getDate()}</Box>
|
||||
</Box>
|
||||
{evs.slice(0, compact ? 3 : 4).map((e, idx) => (
|
||||
<Box
|
||||
key={`${e.workflow.id}-${idx}`}
|
||||
onClick={() => onSelectWorkflow?.(e.workflow.id, e.date)}
|
||||
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
|
||||
sx={{ mt: 0.3, display: 'flex', alignItems: 'center', gap: 0.5, fontSize: EVENT_FS, color: c.text.primary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: accent } }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', boxSizing: 'border-box', bgcolor: accent, flexShrink: 0 }} />
|
||||
<span style={{ color: c.text.muted, flexShrink: 0 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, fontWeight: 500 }}>{e.workflow.title}</span>
|
||||
</Box>
|
||||
))}
|
||||
{evs.length > (compact ? 3 : 4) && (
|
||||
<MonthDayOverflow
|
||||
date={d}
|
||||
count={evs.length - (compact ? 3 : 4)}
|
||||
events={evs}
|
||||
now={now}
|
||||
fontSize={EVENT_FS}
|
||||
onSelectWorkflow={onSelectWorkflow}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{ctxMenuEl}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Apple-Calendar-style list: each day is a stacked group with the date as a
|
||||
// header and its events listed underneath, so a busy day stays readable top
|
||||
// to bottom instead of crammed beside a date column. Today renders even with
|
||||
// no events (shows a "No events today" placeholder)
|
||||
// so the list doesn't feel empty for new users. Off-screen day groups
|
||||
// unmount (useWindowedList) and leave a measured-height spacer behind, so a
|
||||
// dense schedule stays light no matter how far down you scroll.
|
||||
const accent = c.accent.primary;
|
||||
const visibleRows = rows.slice(windowing.start, windowing.end);
|
||||
return (
|
||||
<Box
|
||||
ref={windowing.setScrollEl}
|
||||
onScroll={windowing.onScroll}
|
||||
sx={{ display: 'flex', flexDirection: 'column', maxHeight: '100%', overflow: 'auto', overflowAnchor: 'auto', bgcolor: c.bg.surface }}>
|
||||
{rows.length === 0 && (
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 3 }}>No scheduled</Typography>
|
||||
)}
|
||||
{windowing.topSpacer > 0 && (
|
||||
<Box aria-hidden sx={{ height: windowing.topSpacer, flexShrink: 0, overflowAnchor: 'none' }} />
|
||||
)}
|
||||
{visibleRows.map((row, i) => {
|
||||
const rowIdx = windowing.start + i;
|
||||
if (row.kind === 'header') {
|
||||
return (
|
||||
<Box
|
||||
key={row.id}
|
||||
data-wl-id={row.id}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'baseline', gap: 0.75,
|
||||
px: 2, pt: rowIdx === 0 ? 1.5 : 2, pb: 0.5,
|
||||
borderTop: rowIdx === 0 ? 'none' : `1px dashed ${c.border.subtle}`,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '1.15rem', fontWeight: 700, color: row.isToday ? accent : c.text.primary, lineHeight: 1, letterSpacing: '-0.01em' }}>
|
||||
{row.date.getDate()}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.85rem', fontWeight: 600, color: row.isToday ? accent : c.text.secondary, lineHeight: 1 }}>
|
||||
{WEEKDAY_FULL[row.date.getDay()]}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1 }}>
|
||||
{row.date.toLocaleString('en', { month: 'short' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (row.kind === 'empty') {
|
||||
return (
|
||||
<Box key={row.id} data-wl-id={row.id} sx={{ px: 2, pb: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.ghost }}>No events today</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const e = row.ev;
|
||||
return (
|
||||
<Box
|
||||
key={row.id}
|
||||
data-wl-id={row.id}
|
||||
onClick={() => onSelectWorkflow?.(e.workflow.id, e.date)}
|
||||
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.25,
|
||||
px: 2, py: 0.4,
|
||||
color: c.text.secondary, cursor: 'pointer',
|
||||
'&:hover .ev-title': { color: accent },
|
||||
}}>
|
||||
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: c.radius.sm, flexShrink: 0 }} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{windowing.bottomSpacer > 0 && (
|
||||
<Box aria-hidden sx={{ height: windowing.bottomSpacer, flexShrink: 0, overflowAnchor: 'none' }} />
|
||||
)}
|
||||
{ctxMenuEl}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Apple Calendar style event stack: tiny bars in the hour cell, followed by a
|
||||
// text overflow affordance when the hour has more runs than fit.
|
||||
function EventStack({ events, paused, now, maxVisible, onSelectWorkflow, eventFontSize, onContextWorkflow }: {
|
||||
events: { workflow: Workflow; date: Date }[];
|
||||
paused?: boolean;
|
||||
now: Date;
|
||||
maxVisible: number;
|
||||
onSelectWorkflow?: (id: string, fireAt?: Date) => void;
|
||||
eventFontSize: string;
|
||||
onContextWorkflow?: (workflow: Workflow, e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
if (events.length === 0) return null;
|
||||
const visible = events.slice(0, maxVisible);
|
||||
const rest = events.slice(maxVisible);
|
||||
const accent = c.accent.primary;
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'absolute', left: 4, right: 4, top: 3, bottom: 2, zIndex: 1, display: 'flex', flexDirection: 'column', gap: 0.25, overflow: 'hidden' }}>
|
||||
{visible.map((event, idx) => {
|
||||
const timeLabel = formatTime(event.date.getHours(), event.date.getMinutes());
|
||||
return (
|
||||
<Tooltip key={`${event.workflow.id}-${event.date.getTime()}-${idx}`} title={<EventTooltipBody event={event} />} placement="top" arrow>
|
||||
<Box
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/x-workflow-id', event.workflow.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={() => onSelectWorkflow?.(event.workflow.id, event.date)}
|
||||
onContextMenu={(e) => onContextWorkflow?.(event.workflow, e)}
|
||||
sx={{
|
||||
height: 15,
|
||||
bgcolor: accent,
|
||||
color: '#fff',
|
||||
border: `1px solid ${accent}`,
|
||||
borderRadius: c.radius.sm,
|
||||
px: 0.55, py: 0,
|
||||
fontSize: eventFontSize, fontWeight: 600, lineHeight: '13px',
|
||||
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 0.35,
|
||||
opacity: paused ? 0.45 : 1,
|
||||
boxSizing: 'border-box',
|
||||
'&:hover': { bgcolor: accent },
|
||||
}}>
|
||||
<span style={{ display: 'block', lineHeight: '13px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{event.workflow.title}</span>
|
||||
<span style={{ display: 'block', lineHeight: '13px', color: 'inherit', opacity: 0.85, flexShrink: 0 }}>{timeLabel}</span>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{rest.length > 0 && (
|
||||
<Box
|
||||
onClick={(e) => setAnchor(e.currentTarget)}
|
||||
role="button"
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
color: c.text.muted,
|
||||
fontSize: eventFontSize,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
cursor: 'pointer',
|
||||
px: 0.35,
|
||||
'&:hover': { color: accent },
|
||||
}}>
|
||||
{rest.length} more
|
||||
</Box>
|
||||
)}
|
||||
<Popover
|
||||
open={Boolean(anchor)}
|
||||
anchorEl={anchor}
|
||||
onClose={() => setAnchor(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
|
||||
<Box sx={{ minWidth: 220, p: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.5 }}>
|
||||
{rest.length} more at this hour
|
||||
</Typography>
|
||||
{rest.map((e, idx) => (
|
||||
<Box
|
||||
key={`${e.workflow.id}-${idx}`}
|
||||
onClick={() => { setAnchor(null); onSelectWorkflow?.(e.workflow.id, e.date); }}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 0.5, py: 0.5, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', boxSizing: 'border-box', bgcolor: accent, flexShrink: 0 }} />
|
||||
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, fontWeight: 600 }}>{e.workflow.title}</Typography>
|
||||
<Typography sx={{ fontSize: '0.74rem', color: c.text.muted }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Popover>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// "+N more" on a packed month cell opens a scrollable popover listing every
|
||||
// run that day, so a heavy day isn't a dead end. Past fires keep the hollow
|
||||
// ring the cell rows use, for a consistent at-a-glance "already ran" read.
|
||||
function MonthDayOverflow({ date, count, events, now, fontSize, onSelectWorkflow }: {
|
||||
date: Date;
|
||||
count: number;
|
||||
events: { workflow: Workflow; date: Date }[];
|
||||
now: Date;
|
||||
fontSize: string;
|
||||
onSelectWorkflow?: (id: string, fireAt?: Date) => void;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
const accent = c.accent.primary;
|
||||
return (
|
||||
<>
|
||||
<Typography
|
||||
onClick={(e) => { e.stopPropagation(); setAnchor(e.currentTarget); }}
|
||||
role="button"
|
||||
sx={{ fontSize, color: c.text.muted, mt: 0.3, pl: 1.4, cursor: 'pointer', '&:hover': { color: accent } }}>
|
||||
+{count} more
|
||||
</Typography>
|
||||
<Popover
|
||||
open={Boolean(anchor)}
|
||||
anchorEl={anchor}
|
||||
onClose={() => setAnchor(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}>
|
||||
<Box sx={{ minWidth: 240, maxHeight: 360, overflowY: 'auto', p: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.5 }}>
|
||||
{`${events.length} scheduled · ${date.toLocaleString('en', { weekday: 'short', month: 'short', day: 'numeric' })}`}
|
||||
</Typography>
|
||||
{events.map((e, idx) => (
|
||||
<Box
|
||||
key={`${e.workflow.id}-${idx}`}
|
||||
onClick={() => { setAnchor(null); onSelectWorkflow?.(e.workflow.id, e.date); }}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 0.5, py: 0.5, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', boxSizing: 'border-box', bgcolor: accent, flexShrink: 0 }} />
|
||||
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.workflow.title}</Typography>
|
||||
<Typography sx={{ fontSize: '0.74rem', color: c.text.muted, flexShrink: 0 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EventTooltipBody({ event }: { event: { workflow: Workflow; date: Date } }) {
|
||||
const wf = event.workflow;
|
||||
const status = wf.last_run_status;
|
||||
const cost = wf.cost_estimate?.last_run_usd;
|
||||
const monthly = wf.cost_estimate?.monthly_usd;
|
||||
return (
|
||||
<Box sx={{ fontSize: '0.72rem', lineHeight: 1.5 }}>
|
||||
<div style={{ fontWeight: 700 }}>{wf.title}</div>
|
||||
<div>{`Fires at ${formatTime(event.date.getHours(), event.date.getMinutes())}`}</div>
|
||||
{status && <div>{`Last run: ${status}`}</div>}
|
||||
{typeof cost === 'number' && cost > 0 && <div>{`Last run cost: $${cost.toFixed(4)}`}</div>}
|
||||
{typeof monthly === 'number' && monthly > 0 && <div>{`Est. monthly: $${monthly.toFixed(2)}`}</div>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import BookmarkIcon from '@mui/icons-material/BookmarkBorderRounded';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import CalendarMonthIcon from '@mui/icons-material/CalendarMonthRounded';
|
||||
import OpenInFullIcon from '@mui/icons-material/OpenInFullRounded';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import type { WorkflowRun } from '@/shared/state/workflowsSlice';
|
||||
import ScheduleCalendar from './ScheduleCalendar';
|
||||
import { HistoryList } from './WorkflowCardSubviews';
|
||||
import { addDays, startOfWeek } from './scheduleUtils';
|
||||
|
||||
type Mode = 'search' | 'runs' | 'schedule';
|
||||
|
||||
interface Props {
|
||||
mode: Mode;
|
||||
onModeChange: (m: Mode) => void;
|
||||
historyResults: { id: string; name: string; closed_at: string | null }[];
|
||||
historyLoading: boolean;
|
||||
historyQuery: string;
|
||||
onHistoryQueryChange: (q: string) => void;
|
||||
onHistorySelect: (id: string) => void;
|
||||
onNewChat: () => void;
|
||||
onWorkflowSelect: (id: string) => void;
|
||||
onExpand: () => void;
|
||||
allRuns: WorkflowRun[];
|
||||
allRunsLoading: boolean;
|
||||
onRunOpen: (run: WorkflowRun) => void;
|
||||
workflowTitleFor: (workflowId: string) => string;
|
||||
historyScrollRef?: React.RefObject<HTMLDivElement>;
|
||||
onHistoryScroll?: () => void;
|
||||
/** When true, hides the internal Search/Schedule chips + redundant "+ New"
|
||||
* pill. The new DashboardToolbar pills above the popover replace them. */
|
||||
hideTopChrome?: boolean;
|
||||
/** When true, the dock History is purely chat history: the Scheduled-tasks /
|
||||
* Schedule tabs and views are dropped (workflows live in the Workflows app). */
|
||||
chatHistoryOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function SchedulePopover({
|
||||
mode, onModeChange, historyResults, historyLoading, historyQuery, onHistoryQueryChange,
|
||||
onHistorySelect, onNewChat, onWorkflowSelect, onExpand,
|
||||
allRuns, allRunsLoading, onRunOpen, workflowTitleFor,
|
||||
historyScrollRef, onHistoryScroll,
|
||||
hideTopChrome = false,
|
||||
chatHistoryOnly = false,
|
||||
}: Props) {
|
||||
const showSearch = chatHistoryOnly || mode === 'search';
|
||||
const c = useClaudeTokens();
|
||||
// List leads: it's the at-a-glance "what's coming up" the user wants first,
|
||||
// with Week/Month as the calendar grids behind it.
|
||||
const [calendarView, setCalendarView] = useState<'Week' | 'Month' | 'List'>('List');
|
||||
const [refDate, setRefDate] = useState<Date>(() => new Date());
|
||||
const workflows = useAppSelector((s) => s.workflows.items);
|
||||
|
||||
const periodLabel = useMemo(() => {
|
||||
if (calendarView === 'Month') {
|
||||
return refDate.toLocaleString('en', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
if (calendarView === 'Week') {
|
||||
const start = startOfWeek(refDate);
|
||||
const end = addDays(start, 6);
|
||||
const sameMonth = start.getMonth() === end.getMonth();
|
||||
const startStr = start.toLocaleString('en', { month: 'short', day: 'numeric' });
|
||||
const endStr = sameMonth
|
||||
? String(end.getDate())
|
||||
: end.toLocaleString('en', { month: 'short', day: 'numeric' });
|
||||
return `${startStr} – ${endStr}, ${end.getFullYear()}`;
|
||||
}
|
||||
return refDate.toLocaleString('en', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
}, [refDate, calendarView]);
|
||||
|
||||
const onPrev = useCallback(() => {
|
||||
setRefDate((d) => addDays(d, calendarView === 'Month' ? -28 : calendarView === 'Week' ? -7 : -1));
|
||||
}, [calendarView]);
|
||||
const onNext = useCallback(() => {
|
||||
setRefDate((d) => addDays(d, calendarView === 'Month' ? 28 : calendarView === 'Week' ? 7 : 1));
|
||||
}, [calendarView]);
|
||||
|
||||
const workflowIconMap = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const wf of Object.values(workflows)) {
|
||||
if (wf.source_session_id) m[wf.source_session_id] = wf.icon || wf.title.slice(0, 1).toUpperCase();
|
||||
}
|
||||
return m;
|
||||
}, [workflows]);
|
||||
|
||||
// Both Search and Schedule modes render at the same fixed dimensions so
|
||||
// toggling chips doesn't resize the popover. Schedule sets the floor:
|
||||
// its 7-day calendar needs ~620w x ~420h, search inherits the same.
|
||||
const POPOVER_W = 620;
|
||||
const CONTENT_H = 420;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', width: POPOVER_W, maxWidth: POPOVER_W, gap: 0.75, flexShrink: 0 }}>
|
||||
{/* Floating mode chips. Hidden when the parent toolbar supplies its
|
||||
own pill row (Image #32 / #54); kept around so the legacy callers
|
||||
that surface Schedule mode still have a way in. */}
|
||||
{!hideTopChrome && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 0.5 }}>
|
||||
<ModeChip label="Search" icon={<SearchIcon sx={{ fontSize: 14 }} />} active={mode === 'search'} onClick={() => onModeChange('search')} />
|
||||
<ModeChip label="Schedule" icon={<CalendarMonthIcon sx={{ fontSize: 14 }} />} active={mode === 'schedule'} onClick={() => onModeChange('schedule')} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Content card — separately bordered/rounded, like image #30.
|
||||
Inner content crossfades on tab switch so search↔schedule isn't
|
||||
a jarring jump. Outer card stays fixed-size (W×H) so the toolbar
|
||||
doesn't reflow. */}
|
||||
<Box sx={{
|
||||
width: '100%',
|
||||
height: CONTENT_H,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{/* History tabs. Hidden in schedule mode so the Schedule pill's
|
||||
calendar stays untouched; toggles only Chat history <-> runs and
|
||||
never reaches the calendar from here. */}
|
||||
{!chatHistoryOnly && mode !== 'schedule' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, pt: 1, pb: 0.5, flexShrink: 0 }}>
|
||||
{([['search', 'Chat history'], ['runs', 'Scheduled tasks']] as const).map(([m, label]) => (
|
||||
<Box key={m} onClick={() => onModeChange(m)} role="button" sx={{ fontSize: '0.85rem', fontWeight: mode === m ? 700 : 500, px: 0.75, pt: 0.4, pb: 0.55, color: mode === m ? c.text.primary : c.text.muted, borderBottom: `2px solid ${mode === m ? c.accent.primary : 'transparent'}`, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>{label}</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ flex: 1, position: 'relative', minHeight: 0 }}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={mode}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.14, ease: 'easeOut' }}
|
||||
style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{showSearch && (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, flexShrink: 0 }}>
|
||||
<SearchIcon sx={{ fontSize: 18, color: c.text.muted }} />
|
||||
<InputBase
|
||||
value={historyQuery}
|
||||
onChange={(e) => onHistoryQueryChange(e.target.value)}
|
||||
placeholder="Search past chats..."
|
||||
sx={{ flex: 1, fontSize: '0.85rem', color: c.text.primary, '& input::placeholder': { color: c.text.ghost, opacity: 1 } }}
|
||||
/>
|
||||
{!hideTopChrome && (
|
||||
<Box onClick={onNewChat} role="button" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.78rem', fontWeight: 500, color: c.text.secondary, px: 1, py: 0.45, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated } }}>
|
||||
<AddIcon sx={{ fontSize: 12 }} />
|
||||
New
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box ref={historyScrollRef} onScroll={onHistoryScroll} sx={{ flex: 1, overflowY: 'auto', borderTop: `1px solid ${c.border.subtle}` }}>
|
||||
{historyResults.length === 0 && !historyLoading && (
|
||||
<Typography sx={{ px: 1.5, py: 2.5, fontSize: '0.82rem', color: c.text.muted, textAlign: 'center' }}>{historyQuery ? 'No matching chats' : 'No chat history yet'}</Typography>
|
||||
)}
|
||||
{historyResults.map((entry) => {
|
||||
const hasWorkflow = Boolean(workflowIconMap[entry.id]);
|
||||
return (
|
||||
<Box key={entry.id} onClick={() => onHistorySelect(entry.id)} sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.9, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
|
||||
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{entry.name}</Typography>
|
||||
{/* Only annotate chats that became saved workflows.
|
||||
A small workflow glyph reads as a tag, where the
|
||||
old single-letter chip read as a random initial. */}
|
||||
{hasWorkflow && (
|
||||
<Tooltip title="This chat is saved as a workflow">
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 18, height: 18, borderRadius: '4px', color: c.text.muted }}>
|
||||
<BookmarkIcon sx={{ fontSize: 13 }} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, flexShrink: 0, whiteSpace: 'nowrap' }}>{relTime(entry.closed_at)}</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!chatHistoryOnly && mode === 'runs' && (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.5, py: 1, borderTop: `1px solid ${c.border.subtle}`, minHeight: 0 }}>
|
||||
{allRunsLoading && allRuns.length === 0 ? (
|
||||
<Typography sx={{ px: 0.5, py: 2.5, fontSize: '0.82rem', color: c.text.muted, textAlign: 'center' }}>Loading runs...</Typography>
|
||||
) : (
|
||||
<HistoryList runs={allRuns} onOpen={onRunOpen} showWorkflow workflowTitleFor={workflowTitleFor} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!chatHistoryOnly && mode === 'schedule' && (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, pt: 1, pb: 0.5, flexShrink: 0 }}>
|
||||
{(['List', 'Week', 'Month'] as const).map((v) => (
|
||||
<Box key={v} onClick={() => setCalendarView(v)} role="button" sx={{ fontSize: '0.85rem', fontWeight: calendarView === v ? 700 : 500, px: 0.75, pt: 0.4, pb: 0.55, color: calendarView === v ? c.text.primary : c.text.muted, borderBottom: `2px solid ${calendarView === v ? c.accent.primary : 'transparent'}`, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>{v}</Box>
|
||||
))}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box onClick={onExpand} role="button" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.78rem', fontWeight: 500, color: c.text.secondary, px: 1, py: 0.35, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated } }}>
|
||||
<OpenInFullIcon sx={{ fontSize: 12 }} />
|
||||
Expand
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Period nav: Today pill, prev/next chevrons, range label.
|
||||
Apple Calendar pattern. Keeps the popover usable without
|
||||
forcing a full Expand for date browsing. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 1.5, pb: 0.75, flexShrink: 0 }}>
|
||||
<Box
|
||||
onClick={() => setRefDate(new Date())}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.78rem', fontWeight: 600, color: c.text.secondary,
|
||||
border: `1px solid ${c.border.subtle}`, px: 0.95, py: 0.3,
|
||||
borderRadius: `${c.radius.md}px`, cursor: 'pointer',
|
||||
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
|
||||
}}>Today</Box>
|
||||
<IconButton size="small" onClick={onPrev} sx={{ p: 0.3, color: c.text.muted, '&:hover': { color: c.text.primary } }}><ChevronLeftIcon sx={{ fontSize: 17 }} /></IconButton>
|
||||
<IconButton size="small" onClick={onNext} sx={{ p: 0.3, color: c.text.muted, '&:hover': { color: c.text.primary } }}><ChevronRightIcon sx={{ fontSize: 17 }} /></IconButton>
|
||||
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.primary, ml: 0.25 }}>{periodLabel}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.5, pt: 0, pb: 1, borderTop: `1px solid ${c.border.subtle}`, minHeight: 0 }}>
|
||||
<ScheduleCalendar view={calendarView} density="compact" onSelectWorkflow={onWorkflowSelect} refDate={refDate} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Floating chip rendered ABOVE the popover card (image #30). Active gets a
|
||||
// subtle filled-elevated bg + 1px border; inactive is borderless ghost.
|
||||
function ModeChip({ label, icon, active, onClick }: { label: string; icon: React.ReactNode; active: boolean; onClick: () => void }) {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
fontSize: '0.82rem', fontWeight: active ? 700 : 500,
|
||||
px: 1.1, py: 0.45,
|
||||
cursor: 'pointer',
|
||||
color: active ? c.text.primary : c.text.muted,
|
||||
bgcolor: active ? c.bg.surface : 'transparent',
|
||||
border: `1px solid ${active ? c.border.subtle : 'transparent'}`,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
boxShadow: active ? c.shadow.sm : 'none',
|
||||
'&:hover': { color: c.text.primary, bgcolor: active ? c.bg.surface : c.bg.elevated },
|
||||
}}>
|
||||
{icon}
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function relTime(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (sec < 60) return 'just now';
|
||||
const m = Math.floor(sec / 60); if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onTestFirst: () => void;
|
||||
onScheduleAnyway: () => void;
|
||||
}
|
||||
|
||||
// Shown before scheduling a workflow whose current steps haven't been validated
|
||||
// by a test run. Scheduled fires can't pause to ask for tool permission, so an
|
||||
// untested workflow that needs approval would silently fail on its first run.
|
||||
export default function ScheduleTestWarningDialog({ open, onClose, onTestFirst, onScheduleAnyway }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontSize: '1rem', fontWeight: 700 }}>Test before scheduling?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography sx={{ fontSize: '0.86rem', color: c.text.secondary, lineHeight: 1.5 }}>
|
||||
Scheduled runs can't pause to ask for permission. If this workflow uses tools that
|
||||
need your approval, it could fail when it runs on its own. A quick test run lets you
|
||||
approve those tools now.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onScheduleAnyway}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.secondary, cursor: 'pointer', px: 1, py: 0.5, '&:hover': { color: c.text.primary } }}>
|
||||
Schedule anyway
|
||||
</Box>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onTestFirst}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary, borderRadius: 999, cursor: 'pointer', px: 1.5, py: 0.6, '&:hover': { filter: 'brightness(1.06)' } }}>
|
||||
Test first
|
||||
</Box>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// Vertical step list, the one shared building block across every
|
||||
// workflow card subview. Supports three orthogonal modes that compose:
|
||||
//
|
||||
// editable onChangeStep is set -> each row is a TextareaAutosize
|
||||
// (PreviewView only).
|
||||
// expandable expandable=true -> chevron next to each title;
|
||||
// click reveals the raw prompt body.
|
||||
// live stepStatuses is set -> per-step circle becomes done/active/
|
||||
// failed; Running view also surfaces
|
||||
// activeStepSubtitle + duration.
|
||||
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import TextareaAutosize from '@mui/material/TextareaAutosize';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DeleteOutlineRounded from '@mui/icons-material/DeleteOutlineRounded';
|
||||
import KeyboardArrowDownRounded from '@mui/icons-material/KeyboardArrowDownRounded';
|
||||
import CheckRounded from '@mui/icons-material/CheckRounded';
|
||||
import CloseRounded from '@mui/icons-material/CloseRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { Workflow, WorkflowRun } from '@/shared/state/workflowsSlice';
|
||||
|
||||
export type StepStatus = 'pending' | 'active' | 'done' | 'failed';
|
||||
|
||||
interface Props {
|
||||
workflow?: Workflow | null;
|
||||
steps: Workflow['steps'];
|
||||
runs?: WorkflowRun[];
|
||||
activeRunId?: string | null;
|
||||
framed?: boolean;
|
||||
// Edit mode
|
||||
onChangeStep?: (idx: number, text: string) => void;
|
||||
onDeleteStep?: (idx: number, id: string) => void;
|
||||
// Expand mode
|
||||
expandable?: boolean;
|
||||
expandedIds?: string[];
|
||||
onToggleExpand?: (id: string) => void;
|
||||
// Live mode
|
||||
stepStatuses?: StepStatus[];
|
||||
activeStepSubtitle?: string | null;
|
||||
activeStepDuration?: string | null;
|
||||
// Cap visible rows; render "... N more" beneath when truncated.
|
||||
maxVisible?: number;
|
||||
}
|
||||
|
||||
const CIRCLE_SIZE = 24;
|
||||
const CONNECTOR_X = CIRCLE_SIZE / 2;
|
||||
|
||||
export default function StepList(props: Props) {
|
||||
const {
|
||||
steps, framed, onChangeStep, onDeleteStep,
|
||||
expandable, expandedIds, onToggleExpand,
|
||||
stepStatuses, activeStepSubtitle, activeStepDuration,
|
||||
maxVisible = 4,
|
||||
} = props;
|
||||
const c = useClaudeTokens();
|
||||
if (!steps || steps.length === 0) return null;
|
||||
|
||||
const visible = steps.slice(0, maxVisible);
|
||||
const hiddenCount = Math.max(0, steps.length - visible.length);
|
||||
const expanded = new Set(expandedIds || []);
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', pl: 0, mt: 0.25 }}>
|
||||
{visible.length > 1 && (
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: CONNECTOR_X - 0.5,
|
||||
top: CIRCLE_SIZE * 0.5,
|
||||
bottom: CIRCLE_SIZE * 0.5,
|
||||
// '1px' not 1: MUI sx treats width:1 as 100%, which rendered the
|
||||
// connector as a full-width grey band behind the steps.
|
||||
width: '1px',
|
||||
bgcolor: c.border.medium,
|
||||
opacity: 0.65,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.85 }}>
|
||||
{visible.map((s, idx) => {
|
||||
const status: StepStatus = stepStatuses?.[idx] ?? 'pending';
|
||||
const isActive = status === 'active';
|
||||
const isDone = status === 'done';
|
||||
const isFailed = status === 'failed';
|
||||
const isExpanded = expanded.has(s.id);
|
||||
const label = (s.label || '').trim() || firstWords(s.text, 6);
|
||||
const rawBody = (s.text || '').trim();
|
||||
const hasExpandableBody = Boolean(expandable && rawBody);
|
||||
const canDelete = Boolean(onDeleteStep && steps.length > 1);
|
||||
const deleteButton = onDeleteStep ? (
|
||||
<Tooltip title={canDelete ? 'Remove step' : 'Workflow needs at least one step'}>
|
||||
<span
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{ display: 'inline-flex' }}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`Remove step ${idx + 1}`}
|
||||
disabled={!canDelete}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (canDelete) onDeleteStep(idx, s.id);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
p: 0,
|
||||
color: c.text.muted,
|
||||
flexShrink: 0,
|
||||
opacity: canDelete ? 0.72 : 0.35,
|
||||
'&:hover': {
|
||||
color: c.status.error,
|
||||
bgcolor: c.status.errorBg,
|
||||
opacity: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box key={s.id} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Box
|
||||
onClick={hasExpandableBody && !isActive ? () => onToggleExpand?.(s.id) : undefined}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1.25,
|
||||
position: 'relative',
|
||||
cursor: hasExpandableBody && !isActive ? 'pointer' : 'default',
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
px: (isActive || (isExpanded && hasExpandableBody)) ? 0.5 : 0,
|
||||
py: (isActive || (isExpanded && hasExpandableBody)) ? 0.5 : 0,
|
||||
mx: (isActive || (isExpanded && hasExpandableBody)) ? -0.5 : 0,
|
||||
bgcolor: (isActive || (isExpanded && hasExpandableBody)) ? c.bg.elevated : 'transparent',
|
||||
transition: 'background 0.18s ease',
|
||||
'&:hover': hasExpandableBody && !isActive ? { bgcolor: c.bg.elevated } : {},
|
||||
}}>
|
||||
<StepDisc
|
||||
index={idx}
|
||||
status={status}
|
||||
framed={!!framed}
|
||||
c={c}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
{onChangeStep ? (
|
||||
<TextareaAutosize
|
||||
value={s.text}
|
||||
onChange={(e) => onChangeStep(idx, e.target.value)}
|
||||
minRows={1}
|
||||
style={{
|
||||
width: '100%',
|
||||
resize: 'none',
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.92rem',
|
||||
color: c.text.primary,
|
||||
border: framed ? `1px solid ${c.border.medium}` : '1px solid transparent',
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
background: framed ? c.bg.surface : 'transparent',
|
||||
padding: '6px 10px',
|
||||
lineHeight: 1.45,
|
||||
outline: 'none',
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color 0.12s ease, background 0.12s ease',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minHeight: CIRCLE_SIZE }}>
|
||||
<Typography sx={{
|
||||
fontSize: '0.92rem',
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
color: c.text.primary,
|
||||
lineHeight: 1.45,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{label}
|
||||
</Typography>
|
||||
{isActive && activeStepDuration && (
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mr: hasExpandableBody ? 0 : 0.5, flexShrink: 0 }}>
|
||||
{activeStepDuration}
|
||||
</Typography>
|
||||
)}
|
||||
{deleteButton}
|
||||
{hasExpandableBody && !isActive && (
|
||||
<KeyboardArrowDownRounded sx={{
|
||||
fontSize: 18,
|
||||
color: c.text.muted,
|
||||
transform: isExpanded ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.18s ease',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{/* Active step: tool call subtitle. Sits under the title
|
||||
with a small leading glyph so the user can read it as
|
||||
"what the agent is doing right now". */}
|
||||
{isActive && activeStepSubtitle && (
|
||||
<Typography sx={{
|
||||
fontSize: '0.82rem',
|
||||
color: c.text.secondary,
|
||||
mt: 0.4,
|
||||
display: 'flex', alignItems: 'center', gap: 0.5,
|
||||
}}>
|
||||
<Box component="span" sx={{ display: 'inline-flex', fontSize: 13 }}>{'▢'}</Box>
|
||||
{activeStepSubtitle}
|
||||
</Typography>
|
||||
)}
|
||||
{/* Expanded body: the raw prompt that lives under the
|
||||
LLM label. Soft elevated panel so it reads as a
|
||||
drill-down, not a separate step. */}
|
||||
{hasExpandableBody && isExpanded && (
|
||||
<Box sx={{
|
||||
mt: 0.6,
|
||||
p: 1,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.bg.elevated,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, lineHeight: 1.5, whiteSpace: 'pre-wrap' }}>
|
||||
{rawBody}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{isFailed && undefined}
|
||||
{isDone && undefined}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{hiddenCount > 0 && (
|
||||
<Typography sx={{
|
||||
fontSize: '0.86rem',
|
||||
color: c.text.secondary,
|
||||
mt: 0.6,
|
||||
ml: 0,
|
||||
}}>
|
||||
... {hiddenCount} more
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function StepDisc({ index, status, framed, c }: { index: number; status: StepStatus; framed: boolean; c: ReturnType<typeof useClaudeTokens> }) {
|
||||
if (status === 'done') {
|
||||
return (
|
||||
<Box sx={{
|
||||
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
|
||||
bgcolor: c.text.muted + '55',
|
||||
color: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0, position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
<CheckRounded sx={{ fontSize: 15 }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<Box sx={{
|
||||
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
|
||||
bgcolor: c.status.error,
|
||||
color: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0, position: 'relative', zIndex: 1,
|
||||
boxShadow: `0 0 0 3px ${c.status.error}22`,
|
||||
}}>
|
||||
<CloseRounded sx={{ fontSize: 15 }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (status === 'active') {
|
||||
return (
|
||||
<Box sx={{
|
||||
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
|
||||
border: `2px solid ${c.accent.primary}`,
|
||||
bgcolor: c.bg.surface,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0, position: 'relative', zIndex: 1,
|
||||
animation: 'workflow-step-spin 1.4s linear infinite',
|
||||
'@keyframes workflow-step-spin': {
|
||||
'0%': { boxShadow: `0 0 0 0 ${c.accent.primary}55` },
|
||||
'50%': { boxShadow: `0 0 0 4px ${c.accent.primary}00` },
|
||||
'100%': { boxShadow: `0 0 0 0 ${c.accent.primary}55` },
|
||||
},
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
border: `1.5px solid ${c.accent.primary}`,
|
||||
borderTopColor: 'transparent',
|
||||
animation: 'workflow-step-dot 0.9s linear infinite',
|
||||
'@keyframes workflow-step-dot': {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' },
|
||||
},
|
||||
}} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// pending
|
||||
void framed;
|
||||
void index;
|
||||
return (
|
||||
<Box sx={{
|
||||
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
bgcolor: c.bg.surface,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0, position: 'relative', zIndex: 1,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
function firstWords(s: string, n: number): string {
|
||||
const words = (s || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length <= n) return words.join(' ');
|
||||
return words.slice(0, n).join(' ') + '...';
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
// Run-state views for the workflow card. The card's `view` field flips to
|
||||
// 'running' / 'completed' / 'failed' off of the workflow:run ws stream
|
||||
// (see upsertRun reducer). Each view here renders the same step list
|
||||
// with a different status overlay + a different footer.
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import HistoryIcon from '@mui/icons-material/HistoryRounded';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrowRounded';
|
||||
import StopRounded from '@mui/icons-material/StopRounded';
|
||||
import PauseRounded from '@mui/icons-material/PauseRounded';
|
||||
import RocketLaunchRounded from '@mui/icons-material/RocketLaunchRounded';
|
||||
import BuildRounded from '@mui/icons-material/BuildRounded';
|
||||
import EditOutlined from '@mui/icons-material/EditOutlined';
|
||||
import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined';
|
||||
import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
setCardSidecar,
|
||||
toggleExpandedStep,
|
||||
updateWorkflowCard,
|
||||
type Workflow,
|
||||
type WorkflowRun,
|
||||
} from '@/shared/state/workflowsSlice';
|
||||
import { DEFAULT_CARD_W, DEFAULT_CARD_H, placeCard, removeCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { fetchSession, closeSession, collapseSession } from '@/shared/state/agentsSlice';
|
||||
import type { AppDispatch } from '@/shared/state/store';
|
||||
import StepList, { type StepStatus } from './StepList';
|
||||
|
||||
// Unlink the sidecar AND close the chat card it opened. closeSession is
|
||||
// what makes removal stick: a bare removeCard gets re-added by
|
||||
// reconcileSessions since the run session shares the dashboard.
|
||||
function stopViewingSidecar(dispatch: AppDispatch, workflowId: string, sessionId: string | null | undefined) {
|
||||
dispatch(setCardSidecar({ workflowId, sessionId: null, kind: null }));
|
||||
if (!sessionId) return;
|
||||
dispatch(collapseSession(sessionId));
|
||||
dispatch(removeCard(sessionId));
|
||||
void dispatch(closeSession({ sessionId }));
|
||||
}
|
||||
|
||||
// Helper: open a session next to the workflow card AND mark the card as
|
||||
// sidecar-linked so the footer flips to Stop Watching/Viewing and the
|
||||
// dashboard draws an arrow chip between the two cards.
|
||||
export function useOpenSidecar(workflowId: string) {
|
||||
const dispatch = useAppDispatch();
|
||||
return React.useCallback(async (sessionId: string, kind: 'watching' | 'viewing-completed' | 'viewing-error' | 'testing') => {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const { store } = await import('@/shared/state/store');
|
||||
if (!store.getState().agents.sessions[sessionId]) {
|
||||
try { await dispatch(fetchSession(sessionId)).unwrap(); } catch { /* not fatal */ }
|
||||
}
|
||||
const wfCardPos = store.getState().dashboardLayout.workflowCards[workflowId];
|
||||
if (!store.getState().dashboardLayout.cards[sessionId] && wfCardPos) {
|
||||
dispatch(placeCard({
|
||||
sessionId,
|
||||
x: wfCardPos.x + wfCardPos.width + 60,
|
||||
y: wfCardPos.y,
|
||||
width: DEFAULT_CARD_W,
|
||||
height: DEFAULT_CARD_H,
|
||||
expandedSessionIds: store.getState().agents.expandedSessionIds,
|
||||
}));
|
||||
}
|
||||
dispatch(setPendingFocusAgentId(sessionId));
|
||||
} catch { /* best-effort */ }
|
||||
dispatch(setCardSidecar({ workflowId, sessionId, kind }));
|
||||
}, [dispatch, workflowId]);
|
||||
}
|
||||
|
||||
type ViewMode = 'card' | 'sidecar-linked';
|
||||
|
||||
// ---------- Shared bits ----------
|
||||
|
||||
function ProgressBar({ value, color }: { value: number; color: string }) {
|
||||
const c = useClaudeTokens();
|
||||
const pct = Math.max(0, Math.min(1, value));
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: 4, borderRadius: 999, bgcolor: c.bg.elevated, overflow: 'hidden' }}>
|
||||
<Box sx={{
|
||||
width: `${pct * 100}%`, height: '100%', bgcolor: color,
|
||||
transition: 'width 0.4s ease',
|
||||
boxShadow: `0 0 6px ${color}66`,
|
||||
}} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PillButton({ label, onClick, icon, tone, filled, disabled }: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: React.ReactNode;
|
||||
tone: 'accent' | 'success' | 'danger' | 'muted';
|
||||
filled?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const colorFor = (t: typeof tone) =>
|
||||
t === 'success' ? c.status.success : t === 'danger' ? c.status.error : t === 'accent' ? c.accent.primary : c.text.secondary;
|
||||
const color = colorFor(tone);
|
||||
const bg = filled ? color : 'transparent';
|
||||
const fg = filled ? '#fff' : color;
|
||||
return (
|
||||
<Box
|
||||
onClick={disabled ? undefined : onClick}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
fontSize: '0.86rem', fontWeight: 700,
|
||||
px: 1.4, py: 0.55, borderRadius: 999,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
color: fg, bgcolor: bg,
|
||||
border: filled ? `1px solid ${color}` : `1px solid ${color}55`,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
'&:hover': { filter: 'brightness(1.05)', bgcolor: filled ? color : color + '14' },
|
||||
}}>
|
||||
{icon}
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function GhostTextBtn({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.86rem', fontWeight: 500, color: c.text.secondary,
|
||||
cursor: 'pointer', px: 0.75, py: 0.5,
|
||||
'&:hover': { color: c.text.primary },
|
||||
}}>
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- RunningView (Image #40) ----------
|
||||
|
||||
export function RunningView({ workflow, steps, runs, mode = 'card' }: {
|
||||
workflow: Workflow;
|
||||
steps: Workflow['steps'];
|
||||
runs?: WorkflowRun[];
|
||||
mode?: ViewMode;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
|
||||
const runId = card?.runId || null;
|
||||
const run = useMemo(() => (runs || []).find((r) => r.id === runId) || null, [runs, runId]);
|
||||
|
||||
// Prefer the backend's real active_step_idx (broadcast on each step
|
||||
// bump in executor.execute). Fall back to elapsed/expected heuristic
|
||||
// when the field is missing (older runs or first-frame race).
|
||||
const heuristicIdx = useActiveStepIdx(steps.length, runs, runId);
|
||||
const activeIdx = typeof run?.active_step_idx === 'number' ? run.active_step_idx : heuristicIdx;
|
||||
const statuses: StepStatus[] = steps.map((_, i) =>
|
||||
i < activeIdx ? 'done' : i === activeIdx ? 'active' : 'pending',
|
||||
);
|
||||
const completeCount = statuses.filter((s) => s === 'done').length;
|
||||
const total = steps.length;
|
||||
|
||||
// Tool-call subtitle for the active step. Backend polls the session's
|
||||
// messages at 1.5s cadence and broadcasts on workflow:run as the agent
|
||||
// makes new tool calls. See executor.py _watch_tool_calls.
|
||||
const activeSubtitle = run?.last_tool_label || null;
|
||||
const activeDuration = formatLiveDuration(run);
|
||||
|
||||
const isLinked = mode === 'sidecar-linked' && (card?.sidecarKind === 'watching' || card?.sidecarKind === 'testing');
|
||||
|
||||
const openSidecar = useOpenSidecar(workflow.id);
|
||||
const onWatchLive = useCallback(() => {
|
||||
if (run?.session_id) void openSidecar(run.session_id, 'watching');
|
||||
}, [openSidecar, run?.session_id]);
|
||||
const onStopWatching = useCallback(() => {
|
||||
dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null }));
|
||||
}, [dispatch, workflow.id]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.92rem', fontWeight: 700, color: c.status.success }}>
|
||||
{completeCount} of {total} complete
|
||||
</Typography>
|
||||
</Box>
|
||||
<ProgressBar value={total > 0 ? completeCount / total : 0} color={c.status.success} />
|
||||
<StepList
|
||||
workflow={workflow}
|
||||
steps={steps}
|
||||
stepStatuses={statuses}
|
||||
activeStepSubtitle={activeSubtitle}
|
||||
activeStepDuration={activeDuration}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
|
||||
{isLinked ? (
|
||||
<PillButton
|
||||
label="Stop Watching"
|
||||
tone="danger"
|
||||
filled={false}
|
||||
icon={<VisibilityOffOutlined sx={{ fontSize: 16 }} />}
|
||||
onClick={onStopWatching}
|
||||
/>
|
||||
) : (
|
||||
<PillButton
|
||||
label="Watch Live"
|
||||
tone="muted"
|
||||
filled={false}
|
||||
icon={<VisibilityOutlined sx={{ fontSize: 16 }} />}
|
||||
onClick={onWatchLive}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{/* Stop / Pause live in the header row, rendered by WorkflowCard. */}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function useActiveStepIdx(stepCount: number, runs: WorkflowRun[] | undefined, activeRunId: string | null | undefined): number {
|
||||
const [, setTick] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
if (!activeRunId) return;
|
||||
const id = window.setInterval(() => setTick((t) => (t + 1) % 1000000), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [activeRunId]);
|
||||
if (!activeRunId || !runs) return 0;
|
||||
const active = runs.find((r) => r.id === activeRunId && r.status === 'running');
|
||||
if (!active) return 0;
|
||||
const elapsed = Date.now() - new Date(active.started_at).getTime();
|
||||
const completed = runs.filter((r) => (r.status === 'success' || r.status === 'ran_late') && r.finished_at);
|
||||
if (completed.length === 0) {
|
||||
return Math.min(stepCount - 1, Math.max(0, Math.floor(stepCount / 2)));
|
||||
}
|
||||
const durations = completed.slice(0, 10).map((r) => new Date(r.finished_at!).getTime() - new Date(r.started_at).getTime());
|
||||
const avg = durations.reduce((a, b) => a + b, 0) / durations.length || 1;
|
||||
const ratio = Math.min(0.99, Math.max(0, elapsed / avg));
|
||||
return Math.min(stepCount - 1, Math.floor(ratio * stepCount));
|
||||
}
|
||||
|
||||
function formatLiveDuration(run: WorkflowRun | null): string | null {
|
||||
if (!run || run.status !== 'running') return null;
|
||||
try {
|
||||
const ms = Date.now() - new Date(run.started_at).getTime();
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
return `${m}m`;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ---------- CompletedView (Image #42) ----------
|
||||
|
||||
export function CompletedView({ workflow, steps, runs, mode = 'card' }: {
|
||||
workflow: Workflow;
|
||||
steps: Workflow['steps'];
|
||||
runs?: WorkflowRun[];
|
||||
mode?: ViewMode;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
|
||||
const runId = card?.runId || null;
|
||||
const run = useMemo(() => (runs || []).find((r) => r.id === runId) || null, [runs, runId]);
|
||||
const statuses: StepStatus[] = steps.map(() => 'done');
|
||||
// A run that was being watched live stays tethered to the same chat when it
|
||||
// finishes, so the still-'watching' kind counts as linked too (the slice's
|
||||
// watching->viewing-completed flip can miss on fast runs). Without this the
|
||||
// card offers "View Agent", which spawns a duplicate chat.
|
||||
const isLinked = mode === 'sidecar-linked' && (card?.sidecarKind === 'viewing-completed' || card?.sidecarKind === 'watching');
|
||||
|
||||
const onDone = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved', runId: null, sidecarSessionId: null, sidecarKind: null } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
const onEdit = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit_agent' } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
const openSidecar = useOpenSidecar(workflow.id);
|
||||
const onViewAgent = useCallback(() => {
|
||||
if (run?.session_id) void openSidecar(run.session_id, 'viewing-completed');
|
||||
}, [openSidecar, run?.session_id]);
|
||||
const onStopViewing = useCallback(() => {
|
||||
stopViewingSidecar(dispatch, workflow.id, card?.sidecarSessionId);
|
||||
}, [dispatch, workflow.id, card?.sidecarSessionId]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.6 }}>
|
||||
<Box component="span" sx={{ color: c.status.success, fontSize: 18, lineHeight: 1, mr: 0.25 }}>✓</Box>
|
||||
<Typography sx={{ fontSize: '0.92rem', fontWeight: 700, color: c.status.success }}>
|
||||
{steps.length} of {steps.length} complete
|
||||
</Typography>
|
||||
</Box>
|
||||
<ProgressBar value={1} color={c.status.success} />
|
||||
<StepList
|
||||
workflow={workflow}
|
||||
steps={steps}
|
||||
stepStatuses={statuses}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1.25,
|
||||
p: 1.5, borderRadius: `${c.radius.lg}px`,
|
||||
bgcolor: c.status.successBg,
|
||||
border: `1px solid ${c.status.success}30`,
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.status.success + '22', color: c.status.success,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<RocketLaunchRounded sx={{ fontSize: 16 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
|
||||
Workflow Success!
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45 }}>
|
||||
If you're curious, you can click the green button below to see exactly what the agent did.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
||||
<PillButton
|
||||
label="Edit"
|
||||
tone="muted"
|
||||
filled={false}
|
||||
icon={<EditOutlined sx={{ fontSize: 15 }} />}
|
||||
onClick={onEdit}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{/* Image #43: Done is hidden in sidecar mode; Stop Viewing alone
|
||||
fills the right slot. Default mode keeps Done + View Agent. */}
|
||||
{!isLinked && <GhostTextBtn label="Done" onClick={onDone} />}
|
||||
{isLinked ? (
|
||||
<PillButton
|
||||
label="Stop Viewing"
|
||||
tone="success"
|
||||
filled={false}
|
||||
icon={<VisibilityOffOutlined sx={{ fontSize: 16 }} />}
|
||||
onClick={onStopViewing}
|
||||
/>
|
||||
) : (
|
||||
<PillButton
|
||||
label="View Agent"
|
||||
tone="success"
|
||||
filled
|
||||
onClick={onViewAgent}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- FailedView (Image #46) ----------
|
||||
|
||||
export function FailedView({ workflow, steps, runs, mode = 'card' }: {
|
||||
workflow: Workflow;
|
||||
steps: Workflow['steps'];
|
||||
runs?: WorkflowRun[];
|
||||
mode?: ViewMode;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
|
||||
const runId = card?.runId || null;
|
||||
const run = useMemo(() => (runs || []).find((r) => r.id === runId) || null, [runs, runId]);
|
||||
const failedIdx = guessFailedIdx(run, steps.length);
|
||||
const statuses: StepStatus[] = steps.map((_, i) =>
|
||||
i < failedIdx ? 'done' : i === failedIdx ? 'failed' : 'pending',
|
||||
);
|
||||
// Same as CompletedView: a watched run that fails stays tethered, so treat the
|
||||
// still-'watching' kind as linked and show Stop Viewing instead of View Error
|
||||
// (which would open a second chat).
|
||||
const isLinked = mode === 'sidecar-linked' && (card?.sidecarKind === 'viewing-error' || card?.sidecarKind === 'watching');
|
||||
|
||||
const onIgnore = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved', runId: null, sidecarSessionId: null, sidecarKind: null } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
const openSidecar = useOpenSidecar(workflow.id);
|
||||
const onViewError = useCallback(() => {
|
||||
if (run?.session_id) void openSidecar(run.session_id, 'viewing-error');
|
||||
}, [openSidecar, run?.session_id]);
|
||||
const onStopViewing = useCallback(() => {
|
||||
stopViewingSidecar(dispatch, workflow.id, card?.sidecarSessionId);
|
||||
}, [dispatch, workflow.id, card?.sidecarSessionId]);
|
||||
const onFixWithAgent = useCallback(() => {
|
||||
if (!run) return;
|
||||
const stepLabel = steps[failedIdx]?.label || steps[failedIdx]?.text?.slice(0, 60) || `Step ${failedIdx + 1}`;
|
||||
dispatch(updateWorkflowCard({
|
||||
workflowId: workflow.id,
|
||||
patch: {
|
||||
view: 'fix_agent',
|
||||
sidecarSessionId: null,
|
||||
sidecarKind: null,
|
||||
fixSeed: { runId: run.id, stepIdx: failedIdx, stepLabel, error: run.error || 'Step failed.' },
|
||||
},
|
||||
}));
|
||||
}, [dispatch, workflow.id, run, steps, failedIdx]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
<StepList
|
||||
workflow={workflow}
|
||||
steps={steps}
|
||||
stepStatuses={statuses}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1.25,
|
||||
p: 1.5, borderRadius: `${c.radius.lg}px`,
|
||||
bgcolor: c.status.errorBg,
|
||||
border: `1px solid ${c.status.error}30`,
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.status.error + '22', color: c.status.error,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<BuildRounded sx={{ fontSize: 16 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
|
||||
Fix with an Agent
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45 }}>
|
||||
Have an agent modify, test, and iterate on the workflow until it works as expected.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
||||
{isLinked ? (
|
||||
<PillButton
|
||||
label="Stop Viewing"
|
||||
tone="danger"
|
||||
filled={false}
|
||||
icon={<VisibilityOffOutlined sx={{ fontSize: 16 }} />}
|
||||
onClick={onStopViewing}
|
||||
/>
|
||||
) : (
|
||||
<PillButton
|
||||
label="View Error"
|
||||
tone="muted"
|
||||
filled={false}
|
||||
icon={<VisibilityOutlined sx={{ fontSize: 16 }} />}
|
||||
onClick={onViewError}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<GhostTextBtn label="Ignore" onClick={onIgnore} />
|
||||
<PillButton
|
||||
label="Fix with Agent"
|
||||
tone="danger"
|
||||
filled
|
||||
onClick={onFixWithAgent}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function guessFailedIdx(run: WorkflowRun | null, total: number): number {
|
||||
if (!run) return Math.max(0, total - 1);
|
||||
// Backend pins active_step_idx at the failed step before flipping
|
||||
// status to 'failure'. Prefer that; fall back to parsing "Step N"
|
||||
// out of the error string for legacy runs.
|
||||
if (typeof run.active_step_idx === 'number') {
|
||||
return Math.max(0, Math.min(total - 1, run.active_step_idx));
|
||||
}
|
||||
if (run.error) {
|
||||
const m = /step\s+(\d+)/i.exec(run.error);
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10);
|
||||
if (!Number.isNaN(n) && n >= 1 && n <= total) return n - 1;
|
||||
}
|
||||
}
|
||||
return Math.max(0, Math.min(total - 1, 1));
|
||||
}
|
||||
|
||||
// ---------- Header overrides ----------
|
||||
// The card header normally renders {History | Run}. Running shows
|
||||
// {Stop | Pause}, Completed/Failed keep {History | Run}, Edit/Fix shows
|
||||
// {Discard | Save}, Scheduling shows {Cancel task scheduling}. The
|
||||
// WorkflowCard hands off via this helper so each view can declare its
|
||||
// own header without the parent fanning out a switch.
|
||||
|
||||
export interface HeaderActions {
|
||||
left?: React.ReactNode;
|
||||
right: React.ReactNode;
|
||||
}
|
||||
|
||||
export function useHeaderActions(workflow: Workflow | null, view: string): HeaderActions {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
return useMemo<HeaderActions>(() => {
|
||||
if (!workflow) return { right: null };
|
||||
const HistoryRun = (
|
||||
<>
|
||||
<Box
|
||||
onClick={() => dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'history' } }))}
|
||||
role="button"
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>
|
||||
<HistoryIcon sx={{ fontSize: 15 }} />
|
||||
History
|
||||
</Box>
|
||||
<Box
|
||||
onClick={() => dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }))}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.35,
|
||||
fontSize: '0.82rem', fontWeight: 700,
|
||||
px: 1.1, py: 0.4, borderRadius: 999,
|
||||
bgcolor: c.accent.primary, color: '#fff', cursor: 'pointer',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
<PlayArrowIcon sx={{ fontSize: 15 }} />
|
||||
Run
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
if (view === 'running') {
|
||||
return {
|
||||
right: (
|
||||
<>
|
||||
<Box role="button" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>
|
||||
<StopRounded sx={{ fontSize: 15 }} />
|
||||
Stop
|
||||
</Box>
|
||||
<Box role="button" sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.35,
|
||||
fontSize: '0.82rem', fontWeight: 700,
|
||||
px: 1.1, py: 0.4, borderRadius: 999,
|
||||
bgcolor: c.accent.primary, color: '#fff', cursor: 'pointer',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
<PauseRounded sx={{ fontSize: 15 }} />
|
||||
Pause
|
||||
</Box>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
return { right: HistoryRun };
|
||||
}, [workflow, view, dispatch, c]);
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import HistoryIcon from '@mui/icons-material/HistoryToggleOffRounded';
|
||||
import CalendarMonthRounded from '@mui/icons-material/CalendarMonthRounded';
|
||||
import EditOutlined from '@mui/icons-material/EditOutlined';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
closeWorkflowCard,
|
||||
createWorkflow,
|
||||
toggleExpandedStep,
|
||||
updateWorkflow,
|
||||
updateWorkflowCard,
|
||||
type Workflow,
|
||||
type WorkflowRun,
|
||||
} from '@/shared/state/workflowsSlice';
|
||||
import { placeCard, removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { CostChip, humanDuration, routingFor, StreakBadge } from './workflowVisuals';
|
||||
import StepList from './StepList';
|
||||
import { isScheduleConfigured, needsScheduleTestWarning, stepsSignature } from './scheduleUtils';
|
||||
import ScheduleTestWarningDialog from './ScheduleTestWarningDialog';
|
||||
import { runWorkflowTest } from './runWorkflowTest';
|
||||
import { useOpenSidecar } from './WorkflowCardLiveViews';
|
||||
|
||||
export function statusColor(s: string, c: ReturnType<typeof useClaudeTokens>): string {
|
||||
if (s === 'success') return c.status.success;
|
||||
if (s === 'failure') return c.status.error;
|
||||
if (s === 'ran_late') return c.status.warning;
|
||||
if (s === 'running') return c.accent.primary;
|
||||
return c.text.muted;
|
||||
}
|
||||
|
||||
export function statusBg(s: string, c: ReturnType<typeof useClaudeTokens>): string {
|
||||
if (s === 'success') return c.status.successBg;
|
||||
if (s === 'failure') return c.status.errorBg;
|
||||
if (s === 'ran_late') return c.status.warningBg;
|
||||
return c.bg.secondary;
|
||||
}
|
||||
|
||||
export function labelForStatus(s: string): string {
|
||||
if (s === 'success') return 'Success';
|
||||
if (s === 'failure') return 'Failure';
|
||||
if (s === 'ran_late') return 'Ran late';
|
||||
if (s === 'running') return 'Running';
|
||||
if (s === 'skipped') return 'Skipped';
|
||||
return s;
|
||||
}
|
||||
|
||||
export function formatRunDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('en', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
type ActionBtnTone = 'muted' | 'success' | 'danger';
|
||||
|
||||
export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: string; tone: ActionBtnTone; disabled?: boolean; onClick: () => void; icon?: 'trash' | 'check' }) {
|
||||
const c = useClaudeTokens();
|
||||
// Tone -> color triple. Matches target #58/#63 styling:
|
||||
// success = green pill (Save)
|
||||
// danger = red/pink pill (Discard)
|
||||
// muted = neutral pill (Undo)
|
||||
const palette = tone === 'success'
|
||||
? { color: c.status.success, bg: c.status.successBg, border: c.status.success + '60', hover: c.status.success + '30' }
|
||||
: tone === 'danger'
|
||||
? { color: c.status.error, bg: c.status.errorBg, border: c.status.error + '60', hover: c.status.error + '30' }
|
||||
: { color: c.text.secondary, bg: c.bg.secondary, border: c.border.subtle, hover: c.bg.elevated };
|
||||
return (
|
||||
<Box
|
||||
onClick={disabled ? undefined : onClick}
|
||||
role="button"
|
||||
sx={{
|
||||
// Compact pill matching target #58/#63. Smaller padding + smaller
|
||||
// glyphs so the buttons stop overshadowing the step body.
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
fontSize: '0.78rem', fontWeight: 600,
|
||||
px: 1, py: 0.35,
|
||||
borderRadius: c.radius.full,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
color: palette.color,
|
||||
bgcolor: palette.bg,
|
||||
border: `1px solid ${palette.border}`,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
'&:hover': { bgcolor: palette.hover },
|
||||
}}>
|
||||
{icon === 'trash' && (
|
||||
<Box component="span" sx={{ display: 'inline-flex', fontSize: 12, lineHeight: 1 }}>{'\u{1F5D1}'}</Box>
|
||||
)}
|
||||
{icon === 'check' && (
|
||||
<Box component="span" sx={{ display: 'inline-flex', fontSize: 12, lineHeight: 1 }}>{'✓'}</Box>
|
||||
)}
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, onSaved, onDiscardDraft, closeRequestNonce }: {
|
||||
workflowId: string;
|
||||
steps: Workflow['steps'];
|
||||
sourceSessionId: string | null;
|
||||
initialDraft: Partial<Workflow> | null;
|
||||
onSaved: (w: Workflow, options?: { view?: 'saved' | 'scheduling'; close?: boolean }) => void;
|
||||
onDiscardDraft?: () => void;
|
||||
closeRequestNonce?: number;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [savePromptOpen, setSavePromptOpen] = useState(false);
|
||||
// Title + description live in the openCard draft so the parent header
|
||||
// (which renders the inline-editable title) and PreviewView body (which
|
||||
// renders the inline-editable description + steps) stay in sync. On
|
||||
// Save we pull whatever's currently in the draft, falling back to the
|
||||
// initialDraft passed at mount time.
|
||||
const card = useAppSelector((s) => s.workflows.openCards[workflowId]);
|
||||
const liveDraft = (card?.draft ?? initialDraft ?? {}) as Partial<Workflow>;
|
||||
const title = (liveDraft.title as string) || 'New workflow';
|
||||
const description = (liveDraft.description as string) || '';
|
||||
const canSave = steps.some((s) => (s.text || '').trim().length > 0);
|
||||
// The new workflow runs with the user's configured default model/mode (their
|
||||
// subscription, etc.), falling back to whatever the source chat used. Without
|
||||
// this the backend picks its own default, which surprised users who'd set a
|
||||
// subscription default but saw the workflow created on an API-key model.
|
||||
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
|
||||
const defaultMode = useAppSelector((s) => s.settings.data.default_mode);
|
||||
// Steps render compact (label + chevron, capped + "... N more"), same as
|
||||
// the saved card. The raw prompt drills down on click. Keeping them short
|
||||
// is what leaves room for the schedule prompt + buttons to stay on-card.
|
||||
const expandedIds = card?.expandedStepIds || [];
|
||||
const onToggleStep = useCallback((stepId: string) => {
|
||||
dispatch(toggleExpandedStep({ workflowId, stepId }));
|
||||
}, [dispatch, workflowId]);
|
||||
|
||||
const onDeleteStep = useCallback((idx: number, stepId: string) => {
|
||||
if (steps.length <= 1) return;
|
||||
const nextSteps = steps.filter((_, i) => i !== idx);
|
||||
dispatch(updateWorkflowCard({
|
||||
workflowId,
|
||||
patch: {
|
||||
draft: {
|
||||
...liveDraft,
|
||||
steps: nextSteps,
|
||||
},
|
||||
expandedStepIds: (card?.expandedStepIds || []).filter((id) => id !== stepId),
|
||||
},
|
||||
}));
|
||||
}, [card?.expandedStepIds, dispatch, liveDraft, steps, workflowId]);
|
||||
|
||||
const onChangeDescription = useCallback((value: string) => {
|
||||
dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...liveDraft, description: value } } }));
|
||||
}, [dispatch, workflowId, liveDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (closeRequestNonce) setSavePromptOpen(true);
|
||||
}, [closeRequestNonce]);
|
||||
|
||||
const saveWorkflow = useCallback(async (): Promise<Workflow | null> => {
|
||||
if (!canSave) return null;
|
||||
const result = await dispatch(createWorkflow({
|
||||
title,
|
||||
description,
|
||||
steps: steps.map((s) => ({ id: s.id, text: s.text, label: s.label })),
|
||||
metadata_generated: card?.metaGenerated === true,
|
||||
source_session_id: sourceSessionId,
|
||||
use_synced_prompt: true,
|
||||
// The user's configured default wins over whatever model the source chat
|
||||
// happened to run on, so a converted workflow behaves like a fresh chat.
|
||||
model: defaultModel || (liveDraft.model as string),
|
||||
mode: defaultMode || (liveDraft.mode as string),
|
||||
// Converting a chat carries its prior approvals, so count it as already
|
||||
// validated for these steps: scheduling won't nag to test first.
|
||||
tested_signature: sourceSessionId ? stepsSignature(steps) : undefined,
|
||||
} as Partial<Workflow>));
|
||||
if (!createWorkflow.fulfilled.match(result)) return null;
|
||||
const wf = result.payload as Workflow;
|
||||
if (wf?.id) return wf;
|
||||
return null;
|
||||
}, [canSave, dispatch, title, description, steps, sourceSessionId, liveDraft, defaultModel, defaultMode, card]);
|
||||
|
||||
const onIgnore = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setSavePromptOpen(true);
|
||||
}, [busy]);
|
||||
|
||||
const onSaveThenSchedule = useCallback(async () => {
|
||||
if (busy || !canSave) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const wf = await saveWorkflow();
|
||||
if (wf?.id) onSaved(wf, { view: 'scheduling' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, canSave, saveWorkflow, onSaved]);
|
||||
|
||||
const onSaveDraft = useCallback(async () => {
|
||||
if (busy || !canSave) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const wf = await saveWorkflow();
|
||||
if (wf?.id) onSaved(wf, { view: 'saved', close: true });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setSavePromptOpen(false);
|
||||
}
|
||||
}, [busy, canSave, saveWorkflow, onSaved]);
|
||||
|
||||
const onDontSave = useCallback(() => {
|
||||
setSavePromptOpen(false);
|
||||
onDiscardDraft?.();
|
||||
}, [onDiscardDraft]);
|
||||
|
||||
void onChangeDescription;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
<StepList steps={steps} expandable expandedIds={expandedIds} onToggleExpand={onToggleStep} onDeleteStep={onDeleteStep} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{/* Schedule prompt card. Soft accent tint + calendar icon. Accent is the
|
||||
same color the human-intervention (AskUserQuestion) popup uses. */}
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1.25,
|
||||
p: 1.5, borderRadius: `${c.radius.lg}px`,
|
||||
bgcolor: c.accent.primary + '10',
|
||||
border: `1px solid ${c.accent.primary}30`,
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.accent.primary + '22', color: c.accent.primary,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<CalendarMonthRounded sx={{ fontSize: 18 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
|
||||
Schedule this workflow?
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45 }}>
|
||||
You can have workflows run on a recurring basis, automatically.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1.5 }}>
|
||||
<Box
|
||||
onClick={onIgnore}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.86rem', fontWeight: 500, color: c.text.secondary,
|
||||
cursor: busy ? 'wait' : 'pointer', px: 0.75, py: 0.5,
|
||||
opacity: busy ? 0.6 : 1,
|
||||
'&:hover': { color: c.text.primary },
|
||||
}}>
|
||||
Not now
|
||||
</Box>
|
||||
<Box
|
||||
onClick={canSave ? onSaveThenSchedule : undefined}
|
||||
role="button"
|
||||
title={canSave ? undefined : 'Add at least one step before saving'}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
fontSize: '0.88rem', fontWeight: 700,
|
||||
px: 1.75, py: 0.6, borderRadius: c.radius.full,
|
||||
color: '#fff', bgcolor: c.accent.primary,
|
||||
cursor: busy ? 'wait' : canSave ? 'pointer' : 'not-allowed',
|
||||
opacity: busy || !canSave ? 0.6 : 1,
|
||||
'&:hover': { bgcolor: c.accent.primary, filter: 'brightness(1.06)' },
|
||||
}}>
|
||||
Schedule Workflow
|
||||
</Box>
|
||||
</Box>
|
||||
<Dialog open={savePromptOpen} onClose={() => setSavePromptOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontSize: '1rem', fontWeight: 700 }}>Save workflow?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography sx={{ fontSize: '0.86rem', color: c.text.secondary }}>
|
||||
Save this workflow under Unscheduled. It will not run until you choose a schedule.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onDontSave}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.status.error, cursor: busy ? 'wait' : 'pointer', px: 1, py: 0.5, opacity: busy ? 0.6 : 1 }}>
|
||||
Don't Save
|
||||
</Box>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={() => setSavePromptOpen(false)}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.secondary, cursor: busy ? 'wait' : 'pointer', px: 1, py: 0.5, opacity: busy ? 0.6 : 1 }}>
|
||||
Cancel
|
||||
</Box>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={canSave ? onSaveDraft : undefined}
|
||||
title={canSave ? undefined : 'Add at least one step before saving'}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary, borderRadius: c.radius.full, cursor: busy ? 'wait' : canSave ? 'pointer' : 'not-allowed', px: 1.5, py: 0.6, opacity: busy || !canSave ? 0.6 : 1, '&:hover': { filter: 'brightness(1.06)' } }}>
|
||||
Save
|
||||
</Box>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Render the workflow's permission tiers as a flat prose line so the
|
||||
// SavedView reads like a sentence, not a chip salad. Mirrors target #54.
|
||||
function describePermissions(workflow: Workflow): string {
|
||||
const tiers = workflow.permissions || [];
|
||||
if (tiers.length === 0) return 'Notify me in Open Swarm';
|
||||
const parts: string[] = [];
|
||||
for (const t of tiers) {
|
||||
if (t.kind === 'notify') parts.push('notify in app');
|
||||
else if (t.kind === 'text') parts.push('text');
|
||||
else if (t.kind === 'call') parts.push('call');
|
||||
}
|
||||
return `First ${parts.join(', then ')}`;
|
||||
}
|
||||
|
||||
function describeSchedule(workflow: Workflow): string {
|
||||
const s = workflow.schedule;
|
||||
if (!s.enabled) return 'Not scheduled';
|
||||
const h12 = ((s.hour + 11) % 12) + 1;
|
||||
const ampm = s.hour < 12 ? 'am' : 'pm';
|
||||
const time = s.minute === 0 ? `${h12}${ampm}` : `${h12}:${String(s.minute).padStart(2, '0')}${ampm}`;
|
||||
if (s.repeat_unit === 'minute') return `Every ${s.repeat_every} minutes`;
|
||||
if (s.repeat_unit === 'hour') return s.repeat_every === 1 ? `Hourly at :${String(s.minute).padStart(2, '0')}` : `Every ${s.repeat_every} hours`;
|
||||
if (s.repeat_unit === 'day') return s.repeat_every === 1 ? `Daily at ${time}` : `Every ${s.repeat_every} days at ${time}`;
|
||||
if (s.repeat_unit === 'month') {
|
||||
const day = s.day_of_month ? ` on day ${s.day_of_month}` : '';
|
||||
return s.repeat_every === 1 ? `Monthly${day} at ${time}` : `Every ${s.repeat_every} months${day} at ${time}`;
|
||||
}
|
||||
if (s.on_days.length === 5 && [1,2,3,4,5].every((d) => s.on_days.includes(d))) return `Weekdays at ${time}`;
|
||||
if (s.on_days.length === 2 && [0,6].every((d) => s.on_days.includes(d))) return `Weekends at ${time}`;
|
||||
if (s.on_days.length === 1) {
|
||||
// Image #50: "Mondays at 3pm" (plural day, no "Every" prefix). Reads
|
||||
// more naturally than "Every Mon at 3pm".
|
||||
const plurals = ['Sundays', 'Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays'];
|
||||
return `${plurals[s.on_days[0]]} at ${time}`;
|
||||
}
|
||||
return `Weekly at ${time}`;
|
||||
}
|
||||
|
||||
export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Workflow; steps: Workflow['steps']; runs?: WorkflowRun[]; activeRunId?: string | null }) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
void runs; void activeRunId;
|
||||
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
|
||||
const expandedIds = card?.expandedStepIds || [];
|
||||
const [deletingStepId, setDeletingStepId] = useState<string | null>(null);
|
||||
const openEditAgent = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit_agent' } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
const openSidecar = useOpenSidecar(workflow.id);
|
||||
const [warnOpen, setWarnOpen] = useState(false);
|
||||
const openScheduling = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'scheduling', showScheduleNudge: false } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
// Gate the schedule action: warn first if the current steps haven't been
|
||||
// validated by a test run (so an unattended fire won't silently deny a tool).
|
||||
const requestSchedule = useCallback(() => {
|
||||
if (needsScheduleTestWarning(workflow)) { setWarnOpen(true); return; }
|
||||
openScheduling();
|
||||
}, [workflow, openScheduling]);
|
||||
const onTestFirst = useCallback(() => {
|
||||
setWarnOpen(false);
|
||||
void runWorkflowTest(workflow.id, workflow.draft_steps ?? workflow.steps, openSidecar);
|
||||
}, [workflow.id, workflow.draft_steps, workflow.steps, openSidecar]);
|
||||
const onScheduleAnyway = useCallback(() => {
|
||||
setWarnOpen(false);
|
||||
openScheduling();
|
||||
}, [openScheduling]);
|
||||
const onToggleStep = useCallback((stepId: string) => {
|
||||
dispatch(toggleExpandedStep({ workflowId: workflow.id, stepId }));
|
||||
}, [dispatch, workflow.id]);
|
||||
const onDeleteStep = useCallback(async (idx: number, stepId: string) => {
|
||||
if (workflow.steps.length <= 1 || deletingStepId) return;
|
||||
setDeletingStepId(stepId);
|
||||
try {
|
||||
await dispatch(updateWorkflow({
|
||||
id: workflow.id,
|
||||
patch: { steps: workflow.steps.filter((_, i) => i !== idx) },
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
} finally {
|
||||
setDeletingStepId(null);
|
||||
}
|
||||
}, [deletingStepId, dispatch, workflow.id, workflow.steps, workflow.updated_at]);
|
||||
|
||||
// "Not now" on the post-convert nudge doesn't dump you on a near-identical
|
||||
// saved card: the workflow is already saved (find it in the hub), so we drop
|
||||
// its card and reopen the chat it came from, right in the same slot.
|
||||
const wfCardPos = useAppSelector((s) => s.dashboardLayout.workflowCards[workflow.id]);
|
||||
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
|
||||
const sourceId = workflow.source_session_id || null;
|
||||
const sourceExists = useAppSelector((s) => (sourceId ? !!s.agents.sessions[sourceId] : false));
|
||||
const onNotNow = useCallback(() => {
|
||||
if (sourceId && sourceExists && wfCardPos) {
|
||||
const { x, y, width, height } = wfCardPos;
|
||||
dispatch(removeWorkflowCard(workflow.id));
|
||||
dispatch(closeWorkflowCard(workflow.id));
|
||||
dispatch(placeCard({ sessionId: sourceId, x, y, width, height, expandedSessionIds }));
|
||||
dispatch(setPendingFocusAgentId(sourceId));
|
||||
} else {
|
||||
// No chat to fall back to (rare): just retire the prompt in place.
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { showScheduleNudge: false } }));
|
||||
}
|
||||
}, [dispatch, sourceId, sourceExists, wfCardPos, expandedSessionIds, workflow.id]);
|
||||
|
||||
const scheduleConfigured = isScheduleConfigured(workflow.schedule);
|
||||
const scheduleLine = workflow.schedule.enabled && scheduleConfigured ? describeSchedule(workflow) : 'Schedule this workflow';
|
||||
const scheduleClickable = !scheduleConfigured;
|
||||
// One-shot prompt right after a convert; hub-opened cards never set the flag,
|
||||
// so they fall straight to the quiet schedule line below.
|
||||
const showNudge = !!card?.showScheduleNudge && !scheduleConfigured;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
<StepList
|
||||
workflow={workflow}
|
||||
steps={steps}
|
||||
expandable
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={onToggleStep}
|
||||
onDeleteStep={onDeleteStep}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{showNudge && (
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1.25,
|
||||
p: 1.5, borderRadius: `${c.radius.lg}px`,
|
||||
bgcolor: c.accent.primary + '10',
|
||||
border: `1px solid ${c.accent.primary}30`,
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.accent.primary + '22', color: c.accent.primary,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<CalendarMonthRounded sx={{ fontSize: 18 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
|
||||
Schedule this workflow?
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45 }}>
|
||||
You can have workflows run on a recurring basis, automatically.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1.5, mt: 1.25 }}>
|
||||
<Box
|
||||
onClick={onNotNow}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.86rem', fontWeight: 500, color: c.text.secondary,
|
||||
cursor: 'pointer', px: 0.75, py: 0.5,
|
||||
'&:hover': { color: c.text.primary },
|
||||
}}>
|
||||
Not now
|
||||
</Box>
|
||||
<Box
|
||||
onClick={requestSchedule}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
fontSize: '0.88rem', fontWeight: 700,
|
||||
px: 1.75, py: 0.6, borderRadius: c.radius.full,
|
||||
color: '#fff', bgcolor: c.accent.primary,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: c.accent.primary, filter: 'brightness(1.06)' },
|
||||
}}>
|
||||
Schedule Workflow
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
{showNudge ? <Box /> : (
|
||||
<Box
|
||||
onClick={scheduleClickable ? requestSchedule : undefined}
|
||||
role={scheduleClickable ? 'button' : undefined}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.6,
|
||||
color: c.text.secondary, fontSize: '0.86rem', minWidth: 0,
|
||||
cursor: scheduleClickable ? 'pointer' : 'default',
|
||||
'&:hover': scheduleClickable ? { color: c.text.primary } : {},
|
||||
}}>
|
||||
<CalendarMonthRounded sx={{ fontSize: 16, color: c.text.muted, flexShrink: 0 }} />
|
||||
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{scheduleLine}</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
onClick={openEditAgent}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.45,
|
||||
fontSize: '0.82rem', fontWeight: 600,
|
||||
px: 1.25, py: 0.5,
|
||||
borderRadius: c.radius.full,
|
||||
cursor: 'pointer',
|
||||
color: c.text.secondary,
|
||||
bgcolor: 'transparent',
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
'&:hover': { bgcolor: c.bg.elevated, borderColor: c.border.strong, color: c.text.primary },
|
||||
}}>
|
||||
<EditOutlined sx={{ fontSize: 15 }} />
|
||||
Edit
|
||||
</Box>
|
||||
</Box>
|
||||
<ScheduleTestWarningDialog
|
||||
open={warnOpen}
|
||||
onClose={() => setWarnOpen(false)}
|
||||
onTestFirst={onTestFirst}
|
||||
onScheduleAnyway={onScheduleAnyway}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// kept on file for legacy uses; once the audit popover migrates, this and
|
||||
// the StreakBadge / habit-suggestion blocks above can be deleted entirely.
|
||||
void StreakBadgeRow;
|
||||
|
||||
// Splits StreakBadge out so the SavedView body doesn't have to ferry
|
||||
// the runs array through both the chip row (gone) and the step list.
|
||||
function StreakBadgeRow({ runs }: { runs?: WorkflowRun[] }) {
|
||||
if (!runs || runs.length === 0) return null;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<StreakBadge runs={runs} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Audit-trace popover. Lazy-fetches the last N edits from /workflows/{id}/audit
|
||||
// on open, renders a compact list. The trigger sits inline with the chip
|
||||
// row so power users can spot it without cluttering the title.
|
||||
function AuditTraceLink({ workflowId }: { workflowId: string }) {
|
||||
const c = useClaudeTokens();
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
const [entries, setEntries] = useState<Array<{ ts: string; who: string; diff: Record<string, { before: unknown; after: unknown }> }> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Probe the audit log once on mount so we can hide the trigger entirely
|
||||
// when there are no edits (item #21 in target #54 diff). Fire-and-forget;
|
||||
// a failure leaves entries=null which renders nothing.
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const { API_BASE, getAuthToken } = await import('@/shared/config');
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflowId)}/audit?limit=5`, {
|
||||
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
|
||||
});
|
||||
const data = await res.json();
|
||||
if (alive) setEntries(Array.isArray(data?.entries) ? data.entries : []);
|
||||
} catch {
|
||||
if (alive) setEntries([]);
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [workflowId]);
|
||||
// The popover open handler must be declared BEFORE the conditional
|
||||
// return below; otherwise React sees a different hook-count between
|
||||
// the "loading" render (returns early) and the "loaded with entries"
|
||||
// render (calls useCallback), which triggers the "Rendered more hooks
|
||||
// than during the previous render" crash.
|
||||
const open = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setAnchor(e.currentTarget);
|
||||
if (entries !== null) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { API_BASE, getAuthToken } = await import('@/shared/config');
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflowId)}/audit?limit=5`, {
|
||||
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
|
||||
});
|
||||
const data = await res.json();
|
||||
setEntries(Array.isArray(data?.entries) ? data.entries : []);
|
||||
} catch {
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [entries, workflowId]);
|
||||
// Hide entirely until we know whether there are edits to surface.
|
||||
if (entries === null || entries.length === 0) return null;
|
||||
const close = () => setAnchor(null);
|
||||
const count = entries?.length ?? 0;
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="Recent edits to this workflow">
|
||||
<Box onClick={open} role="button" sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.3,
|
||||
fontSize: '0.7rem', color: c.text.muted, cursor: 'pointer',
|
||||
px: 0.5, py: 0.25, borderRadius: c.radius.sm,
|
||||
'&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated },
|
||||
}}>
|
||||
<HistoryIcon sx={{ fontSize: 12 }} />
|
||||
{entries === null ? 'edits' : `${count} edit${count === 1 ? '' : 's'}`}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
open={Boolean(anchor)}
|
||||
anchorEl={anchor}
|
||||
onClose={close}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
|
||||
<Box sx={{ minWidth: 280, maxWidth: 360, p: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.5 }}>
|
||||
RECENT EDITS
|
||||
</Typography>
|
||||
{loading && <Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>Loading…</Typography>}
|
||||
{!loading && (entries === null || entries.length === 0) && (
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>No edits yet.</Typography>
|
||||
)}
|
||||
{!loading && entries && entries.map((e, idx) => {
|
||||
const fields = Object.keys(e.diff || {}).filter((k) => k !== 'updated_at');
|
||||
const summary = fields.length === 0 ? 'no field changes' : fields.slice(0, 3).join(', ') + (fields.length > 3 ? `, +${fields.length - 3} more` : '');
|
||||
return (
|
||||
<Box key={idx} sx={{ display: 'flex', flexDirection: 'column', py: 0.5, borderTop: idx === 0 ? 'none' : `1px solid ${c.border.subtle}` }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary, fontWeight: 600 }}>{e.who || 'user'}</Typography>
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost }}>{relTimeShort(e.ts)}</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.74rem', color: c.text.secondary }}>{summary}</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function relTimeShort(iso: string): string {
|
||||
try {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (ms < 60000) return 'just now';
|
||||
const m = Math.floor(ms / 60000);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d ago`;
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function runDuration(r: WorkflowRun): string | null {
|
||||
if (!r.finished_at) return null;
|
||||
try {
|
||||
const ms = new Date(r.finished_at).getTime() - new Date(r.started_at).getTime();
|
||||
if (ms <= 0) return null;
|
||||
return humanDuration(ms);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Groups runs into "This week / Last week / Month YYYY" buckets so a
|
||||
// long history list reads as eras rather than 50 same-looking dates.
|
||||
function groupKey(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const day = 24 * 3600 * 1000;
|
||||
const startOfWeek = (x: Date) => { const y = new Date(x); y.setHours(0, 0, 0, 0); y.setDate(y.getDate() - y.getDay()); return y; };
|
||||
const thisWeekStart = startOfWeek(now).getTime();
|
||||
const lastWeekStart = thisWeekStart - 7 * day;
|
||||
if (d.getTime() >= thisWeekStart) return 'This week';
|
||||
if (d.getTime() >= lastWeekStart) return 'Last week';
|
||||
return d.toLocaleString('en', { month: 'long', year: 'numeric' });
|
||||
} catch { return 'Earlier'; }
|
||||
}
|
||||
|
||||
export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleFor }: { runs: WorkflowRun[]; onOpen: (r: WorkflowRun) => void; showWorkflow?: boolean; workflowTitleFor?: (workflowId: string) => string }) {
|
||||
const c = useClaudeTokens();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
// Filter chips: all / success / failures / skipped. Power-users debugging a
|
||||
// flaky workflow shouldn't have to scroll past the runs they don't care about.
|
||||
const [filter, setFilter] = useState<'all' | 'success' | 'failure' | 'skipped'>('all');
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === 'all') return runs;
|
||||
return (runs || []).filter((r) => r.status === filter);
|
||||
}, [runs, filter]);
|
||||
const groups = useMemo(() => {
|
||||
const out: Array<{ key: string; runs: WorkflowRun[] }> = [];
|
||||
for (const r of filtered || []) {
|
||||
const k = groupKey(r.started_at);
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.key === k) last.runs.push(r);
|
||||
else out.push({ key: k, runs: [r] });
|
||||
}
|
||||
return out;
|
||||
}, [filtered]);
|
||||
if (!runs || runs.length === 0) {
|
||||
return <Typography sx={{ fontSize: '0.88rem', color: c.text.muted, py: 1.5, textAlign: 'center' }}>No runs yet</Typography>;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.75 }}>
|
||||
{groups.length > 0 && (
|
||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em' }}>
|
||||
{groups[0].key.toUpperCase()}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{(['all', 'success', 'failure', 'skipped'] as const).map((k) => (
|
||||
<Box key={k} onClick={() => setFilter(k)} role="button" sx={{
|
||||
fontSize: '0.72rem', fontWeight: 600,
|
||||
color: filter === k ? c.accent.primary : c.text.muted,
|
||||
bgcolor: filter === k ? c.accent.primary + '14' : 'transparent',
|
||||
border: `1px solid ${filter === k ? c.accent.primary + '40' : c.border.subtle}`,
|
||||
px: 0.75, py: 0.3, borderRadius: c.radius.full, cursor: 'pointer',
|
||||
'&:hover': { color: c.accent.primary },
|
||||
}}>
|
||||
{k === 'all' ? 'All' : k === 'success' ? 'Success' : k === 'failure' ? 'Failures' : 'Skipped'}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{groups.map(({ key, runs: gRuns }, gi) => (
|
||||
<Box key={key} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{gi > 0 && (
|
||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mt: 0.5, mb: 0.25 }}>
|
||||
{key.toUpperCase()}
|
||||
</Typography>
|
||||
)}
|
||||
{gRuns.map((r) => {
|
||||
const expanded = expandedId === r.id;
|
||||
const dur = runDuration(r);
|
||||
return (
|
||||
<Box key={r.id}>
|
||||
<Box
|
||||
onClick={() => setExpandedId(expanded ? null : r.id)}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.6, px: 0.5, cursor: 'pointer', borderRadius: c.radius.sm, '&:hover': { bgcolor: c.bg.elevated } }}>
|
||||
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(r.status, c), bgcolor: statusBg(r.status, c), px: 0.8, py: 0.3, borderRadius: c.radius.sm, minWidth: 64, textAlign: 'center' }}>
|
||||
{labelForStatus(r.status)}
|
||||
</Box>
|
||||
{showWorkflow && workflowTitleFor ? (
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{workflowTitleFor(r.workflow_id)}</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost }}>{formatRunDate(r.started_at)}</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary, flex: 1 }}>{formatRunDate(r.started_at)}</Typography>
|
||||
)}
|
||||
{dur && <Typography sx={{ fontSize: '0.74rem', color: c.text.ghost }}>{dur}</Typography>}
|
||||
{r.cost_usd > 0 && <Typography sx={{ fontSize: '0.74rem', color: c.text.ghost }}>${r.cost_usd.toFixed(4)}</Typography>}
|
||||
{/* Chevron makes the row read as expandable instead of
|
||||
static text. Rotates 180° while open so the affordance
|
||||
stays visible after click. */}
|
||||
<Box sx={{ fontSize: '0.7rem', color: c.text.ghost, transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s ease' }}>▾</Box>
|
||||
</Box>
|
||||
{expanded && (
|
||||
<Box sx={{ ml: 8, mt: 0.25, mb: 0.75, px: 1, py: 0.75, bgcolor: c.bg.elevated, borderRadius: c.radius.sm, border: `1px solid ${c.border.subtle}`, display: 'flex', alignItems: 'center' }}>
|
||||
|
||||
{r.error ? (
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.status.error, lineHeight: 1.4 }}>{r.error}</Typography>
|
||||
) : r.session_id ? (
|
||||
<Box onClick={(e) => { e.stopPropagation(); onOpen(r); }} role="button" sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}>
|
||||
Click to see the full conversation →
|
||||
</Box>
|
||||
) : (
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.4 }}>No session was recorded for this run.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryDetail({ run, onBack }: { run: WorkflowRun | null; onBack: () => void }) {
|
||||
const c = useClaudeTokens();
|
||||
if (!run) return <Typography sx={{ fontSize: '0.88rem', color: c.text.muted }}>Run not found</Typography>;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box onClick={onBack} role="button" sx={{ fontSize: '0.82rem', color: c.text.muted, cursor: 'pointer', '&:hover': { color: c.accent.primary } }}>← back</Box>
|
||||
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(run.status, c), bgcolor: statusBg(run.status, c), px: 0.8, py: 0.3, borderRadius: c.radius.sm }}>{labelForStatus(run.status)}</Box>
|
||||
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary, fontWeight: 600 }}>{formatRunDate(run.started_at)}</Typography>
|
||||
</Box>
|
||||
{run.error && (
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.status.error, bgcolor: c.status.errorBg, p: 1, borderRadius: c.radius.sm }}>{run.error}</Typography>
|
||||
)}
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.secondary, lineHeight: 1.5 }}>Started {formatRunDate(run.started_at)}, finished {run.finished_at ? formatRunDate(run.finished_at) : 'in progress'}.</Typography>
|
||||
{run.session_id && (
|
||||
<Box sx={{ fontSize: '0.82rem', color: c.accent.primary, mt: 0.5 }}>Session: {run.session_id.slice(0, 8)}</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Clickable "your {workflow} is running now" nudge for scheduled runs that
|
||||
// fire while the user isn't looking. Detection lives in the upsertRun reducer
|
||||
// (it owns the into-running edge); this just renders the redux toast state and,
|
||||
// on View, opens the Workflows app to that workflow's live detail.
|
||||
|
||||
import React from 'react';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Button from '@mui/material/Button';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { dismissRunningToast } from '@/shared/state/workflowsSlice';
|
||||
import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
export default function WorkflowRunningToast() {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const toast = useAppSelector((s) => s.workflows.runningToast);
|
||||
|
||||
const onView = React.useCallback(() => {
|
||||
if (!toast) return;
|
||||
dispatch(openWorkflowsApp({ workflowId: toast.workflowId }));
|
||||
dispatch(dismissRunningToast());
|
||||
}, [toast, dispatch]);
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={Boolean(toast)}
|
||||
autoHideDuration={10000}
|
||||
onClose={(_, reason) => { if (reason !== 'clickaway') dispatch(dismissRunningToast()); }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<Alert
|
||||
icon={false}
|
||||
severity="info"
|
||||
onClose={() => dispatch(dismissRunningToast())}
|
||||
sx={{
|
||||
bgcolor: c.bg.surface,
|
||||
color: c.text.primary,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
'& .MuiAlert-action': { alignItems: 'center', pt: 0 },
|
||||
}}
|
||||
action={
|
||||
<Button size="small" onClick={onView} sx={{ color: c.accent.primary, fontWeight: 700 }}>
|
||||
View
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{toast ? `${toast.workflowTitle} is running now` : ''}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import React, { useMemo, useRef, useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import { startOfWeek, startOfMonthGrid, addDays, sameDay } from '@/app/pages/Workflows/scheduleUtils';
|
||||
import { useCalendarOccurrences } from './useCalendarOccurrences';
|
||||
import { colorForWorkflow, useWC, type WCPalette } from './uiKit';
|
||||
import type { AppNav } from './types';
|
||||
|
||||
interface Occ { wfId: string; title: string; at: Date; color: string; }
|
||||
interface DayPop { title: string; runs: Occ[]; x: number; y: number; }
|
||||
type OpenDayPop = (title: string, runs: Occ[], e: React.MouseEvent) => void;
|
||||
|
||||
const DOW = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
|
||||
const POP_W = 240;
|
||||
|
||||
function miniTime(d: Date): string {
|
||||
let h = d.getHours();
|
||||
const m = d.getMinutes();
|
||||
const ap = h < 12 ? 'am' : 'pm';
|
||||
h = h % 12 === 0 ? 12 : h % 12;
|
||||
return `${h}:${String(m).padStart(2, '0')}${ap}`;
|
||||
}
|
||||
function hourLabel(h: number): string {
|
||||
if (h === 0) return '12 AM';
|
||||
if (h < 12) return `${h} AM`;
|
||||
if (h === 12) return '12 PM';
|
||||
return `${h - 12} PM`;
|
||||
}
|
||||
|
||||
const tabBtn = (active: boolean, WC: WCPalette): CSSProperties => ({
|
||||
padding: '5px 13px', borderRadius: 7, border: 'none', cursor: 'pointer', fontSize: 12.5, fontWeight: 600,
|
||||
background: active ? WC.paper : 'transparent', color: active ? WC.ink : WC.muted,
|
||||
boxShadow: active ? WC.shadow.sm : 'none',
|
||||
});
|
||||
|
||||
const CalendarView: React.FC<{ nav: AppNav }> = ({ nav }) => {
|
||||
const WC = useWC();
|
||||
const items = useAppSelector((s) => s.workflows.items);
|
||||
// Tick the clock so the now-line and "today" highlight stay live instead of
|
||||
// freezing at first render.
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 60000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const ref = nav.refDate;
|
||||
const refKey = `${ref.getFullYear()}-${ref.getMonth()}-${ref.getDate()}`;
|
||||
|
||||
// Window of occurrences spanning the visible month grid (covers week too).
|
||||
// Fired times come from the backend's recurrence engine, not a JS reimpl, so
|
||||
// the grid matches what actually runs (timezone + last-day-of-month aware).
|
||||
const { fromIso, toIso } = useMemo(() => {
|
||||
const from = startOfMonthGrid(ref);
|
||||
return { fromIso: from.toISOString(), toIso: addDays(from, 42).toISOString() };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refKey]);
|
||||
const { events } = useCalendarOccurrences(fromIso, toIso);
|
||||
const occ = useMemo<Occ[]>(() => {
|
||||
const out: Occ[] = [];
|
||||
for (const e of events) {
|
||||
const wf = items[e.workflowId];
|
||||
if (!wf || wf.unsaved) continue;
|
||||
out.push({ wfId: wf.id, title: wf.title || 'Untitled', at: e.at, color: colorForWorkflow(wf) });
|
||||
}
|
||||
return out.sort((a, b) => a.at.getTime() - b.at.getTime());
|
||||
}, [events, items]);
|
||||
|
||||
const occByDay = useMemo(() => {
|
||||
const map = new Map<string, Occ[]>();
|
||||
for (const o of occ) {
|
||||
const key = `${o.at.getFullYear()}-${o.at.getMonth()}-${o.at.getDate()}`;
|
||||
const arr = map.get(key) || [];
|
||||
arr.push(o);
|
||||
map.set(key, arr);
|
||||
}
|
||||
return map;
|
||||
}, [occ]);
|
||||
const dayKey = (d: Date) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
|
||||
const title = ref.toLocaleDateString([], { month: 'long', year: 'numeric' });
|
||||
const step = (dir: number) => {
|
||||
if (nav.calView === 'week') nav.setRefDate(addDays(ref, dir * 7));
|
||||
else nav.setRefDate(new Date(ref.getFullYear(), ref.getMonth() + dir, 1));
|
||||
};
|
||||
|
||||
// Click "+N more" to peek a day's/hour's full run list. position:fixed via a
|
||||
// body portal so it isn't reparented by the zoomed/panned canvas transform.
|
||||
const [dayPop, setDayPop] = useState<DayPop | null>(null);
|
||||
const openDayPop: OpenDayPop = (popTitle, runs, e) => {
|
||||
e.stopPropagation();
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const vw = window.innerWidth, vh = window.innerHeight;
|
||||
const x = Math.min(r.left, vw - POP_W - 12);
|
||||
let y = r.bottom + 6;
|
||||
if (y > vh - 220) y = Math.max(12, r.top - 8 - 300);
|
||||
setDayPop({ title: popTitle, runs, x, y });
|
||||
};
|
||||
const selectFromPop = (id: string) => { setDayPop(null); nav.selectWorkflow(id); };
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: WC.page }}>
|
||||
<div style={{ flex: 'none', padding: '14px 26px', borderBottom: `1px solid ${WC.line}`, display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<button onClick={() => nav.setRefDate(new Date())} style={{ background: WC.paper, border: `1px solid rgba(${WC.inkRGB},0.14)`, borderRadius: 8, padding: '6px 14px', fontSize: 13, fontWeight: 600, color: WC.ink, cursor: 'pointer' }}>Today</button>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => step(-1)} style={{ width: 30, height: 30, borderRadius: 8, border: `1px solid rgba(${WC.inkRGB},0.12)`, background: WC.paper, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.ink3 }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M15 6l-6 6 6 6" /></svg></button>
|
||||
<button onClick={() => step(1)} style={{ width: 30, height: 30, borderRadius: 8, border: `1px solid rgba(${WC.inkRGB},0.12)`, background: WC.paper, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.ink3 }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 6l6 6-6 6" /></svg></button>
|
||||
</div>
|
||||
<h1 style={{ margin: 0, fontFamily: "'Newsreader',serif", fontSize: 22, fontWeight: 600, color: WC.ink, letterSpacing: '-0.01em' }}>{title}</h1>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div style={{ display: 'flex', background: WC.inset, border: `1px solid ${WC.line}`, borderRadius: 9, padding: 3, gap: 2 }}>
|
||||
<button onClick={() => nav.setCalView('week')} style={tabBtn(nav.calView === 'week', WC)}>Week</button>
|
||||
<button onClick={() => nav.setCalView('month')} style={tabBtn(nav.calView === 'month', WC)}>Month</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{nav.calView === 'month'
|
||||
? <MonthGrid ref0={ref} now={now} occByDay={occByDay} dayKey={dayKey} onSelect={nav.selectWorkflow} openDayPop={openDayPop} />
|
||||
: <WeekGrid ref0={ref} now={now} occByDay={occByDay} dayKey={dayKey} onSelect={nav.selectWorkflow} openDayPop={openDayPop} />}
|
||||
|
||||
{dayPop && createPortal(
|
||||
<div onClick={() => setDayPop(null)} style={{ position: 'fixed', inset: 0, zIndex: 2147483600 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ position: 'fixed', left: dayPop.x, top: dayPop.y, width: POP_W, maxHeight: 320, overflowY: 'auto', background: WC.paper, border: `1px solid ${WC.line2}`, borderRadius: WC.radius.lg, boxShadow: WC.shadow.lg, padding: 12 }}>
|
||||
<div style={{ fontFamily: "'Newsreader',serif", fontSize: 15, fontWeight: 500, color: WC.ink, marginBottom: 10, padding: '0 2px' }}>{dayPop.title}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{dayPop.runs.map((r, i) => (
|
||||
<div key={i} onClick={() => selectFromPop(r.wfId)} style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '8px 7px', borderRadius: 8, cursor: 'pointer' }}>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', background: r.color, flex: 'none' }} />
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, color: WC.ink4, flex: 'none', minWidth: 52 }}>{miniTime(r.at)}</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600, color: WC.ink, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface GridProps {
|
||||
ref0: Date; now: Date;
|
||||
occByDay: Map<string, Occ[]>;
|
||||
dayKey: (d: Date) => string;
|
||||
onSelect: (id: string) => void;
|
||||
openDayPop: OpenDayPop;
|
||||
}
|
||||
|
||||
const moreStyle = (WC: WCPalette): CSSProperties => ({
|
||||
fontSize: 10.5, fontWeight: 600, color: WC.muted, padding: '2px 3px', borderRadius: 5,
|
||||
cursor: 'pointer', alignSelf: 'flex-start', border: 'none', background: 'transparent',
|
||||
});
|
||||
|
||||
const MonthGrid: React.FC<GridProps> = ({ ref0, now, occByDay, dayKey, onSelect, openDayPop }) => {
|
||||
const WC = useWC();
|
||||
const start = startOfMonthGrid(ref0);
|
||||
// Only as many weeks as the month actually spans (5 or 6), like the design,
|
||||
// so rows aren't squashed by a dangling extra week of next-month days.
|
||||
const monthEnd = new Date(ref0.getFullYear(), ref0.getMonth() + 1, 0);
|
||||
const weeks = Math.ceil((Math.round((monthEnd.getTime() - start.getTime()) / 86400000) + 1) / 7);
|
||||
const cells = Array.from({ length: weeks * 7 }, (_, i) => addDays(start, i));
|
||||
const month = ref0.getMonth();
|
||||
|
||||
// Cells shrink with the window, so a fixed cap clips. Measure the real row and
|
||||
// "+more" heights off hidden probes (font metrics vary), then fit only events
|
||||
// that fully fit, the rest roll into "+N more". No guessed pixel constants.
|
||||
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||
const probeEventRef = useRef<HTMLDivElement | null>(null);
|
||||
const probeMoreRef = useRef<HTMLSpanElement | null>(null);
|
||||
const [rowH, setRowH] = useState(0);
|
||||
const [eventH, setEventH] = useState(18);
|
||||
const [moreH, setMoreH] = useState(18);
|
||||
useLayoutEffect(() => {
|
||||
const el = gridRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
setRowH(el.clientHeight / weeks);
|
||||
if (probeEventRef.current) setEventH(probeEventRef.current.offsetHeight);
|
||||
if (probeMoreRef.current) setMoreH(probeMoreRef.current.offsetHeight);
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [weeks]);
|
||||
const GAP = 2; // events-container row gap
|
||||
const CELL_OVERHEAD = 39; // number row (27) + vertical padding (12), our own fixed pixels
|
||||
const contentH = rowH - CELL_OVERHEAD;
|
||||
const fitNoMore = Math.max(0, Math.floor((contentH + GAP) / (eventH + GAP)));
|
||||
const fitWithMore = Math.max(0, Math.floor((contentH + 1 - moreH) / (eventH + GAP)));
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div aria-hidden style={{ position: 'absolute', top: -9999, left: -9999, visibility: 'hidden', pointerEvents: 'none' }}>
|
||||
<div ref={probeEventRef} style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '1px 3px' }}>
|
||||
<div style={{ width: 6, height: 6, borderRadius: '50%' }} />
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10 }}>12:00am</span>
|
||||
<span style={{ fontSize: 11 }}>Sample</span>
|
||||
</div>
|
||||
<span ref={probeMoreRef} style={moreStyle(WC)}>+0 more</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', flex: 'none', borderBottom: `1px solid ${WC.line}` }}>
|
||||
{DOW.map((d) => <div key={d} style={{ textAlign: 'center', padding: '9px 0', fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, letterSpacing: '0.06em', color: WC.muted2 }}>{d}</div>)}
|
||||
</div>
|
||||
<div ref={gridRef} style={{ flex: 1, display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gridAutoRows: '1fr', minHeight: 0 }}>
|
||||
{cells.map((d, i) => {
|
||||
const inMonth = d.getMonth() === month;
|
||||
const isToday = sameDay(d, now);
|
||||
const runs = occByDay.get(dayKey(d)) || [];
|
||||
const shown = runs.length <= fitNoMore ? runs : runs.slice(0, fitWithMore);
|
||||
const moreCount = runs.length - shown.length;
|
||||
return (
|
||||
<div key={i} style={{ borderRight: `1px solid rgba(${WC.inkRGB},0.06)`, borderBottom: `1px solid rgba(${WC.inkRGB},0.06)`, padding: '6px 8px', background: isToday ? 'rgba(194,90,54,0.05)' : (inMonth ? WC.paper : WC.inset), display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', marginBottom: 3 }}>
|
||||
<div style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none', background: isToday ? WC.accent : 'transparent' }}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 12, fontWeight: isToday ? 700 : 500, lineHeight: 1, color: isToday ? '#fff' : (inMonth ? WC.ink3 : WC.faint) }}>{d.getDate()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', gap: 2, overflow: 'hidden' }}>
|
||||
{shown.map((r, ri) => (
|
||||
<div key={ri} onClick={() => onSelect(r.wfId)} style={{ display: 'flex', alignItems: 'center', gap: 5, cursor: 'pointer', borderRadius: 4, padding: '1px 3px' }}>
|
||||
<div style={{ width: 6, height: 6, borderRadius: '50%', background: r.color, flex: 'none' }} />
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, color: WC.ink4, flex: 'none' }}>{miniTime(r.at)}</span>
|
||||
<span style={{ fontSize: 11, color: WC.ink2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{moreCount > 0 && (
|
||||
<span
|
||||
onClick={(e) => openDayPop(d.toLocaleDateString([], { month: 'long', day: 'numeric' }), runs, e)}
|
||||
style={{ ...moreStyle(WC), flex: 'none', marginTop: 1 }}
|
||||
>+{moreCount} more</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WeekGrid: React.FC<GridProps> = ({ ref0, now, occByDay, dayKey, onSelect, openDayPop }) => {
|
||||
const WC = useWC();
|
||||
const start = startOfWeek(ref0);
|
||||
const startMs = start.getTime();
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const ROW_H = 44;
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to ~2h before now once per mount / week change. Keying this on the
|
||||
// fresh `now`/`start` objects re-ran it on every render, so any background
|
||||
// re-render (a live run streaming, ongoing-runs updating) yanked the scroll
|
||||
// back up, you could never sit at the bottom. Key off the stable week ms.
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = Math.max(0, (new Date().getHours() - 2) * ROW_H);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [startMs]);
|
||||
|
||||
const nowFrac = (now.getHours() * 60 + now.getMinutes()) / 60 % 1;
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '56px repeat(7,1fr)', flex: 'none', borderBottom: `1px solid ${WC.line}`, paddingRight: 9 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 7, fontFamily: "'JetBrains Mono',monospace", fontSize: 9.5, color: WC.muted2 }} />
|
||||
{days.map((d, i) => {
|
||||
const isToday = sameDay(d, now);
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 0 7px', borderLeft: `1px solid rgba(${WC.inkRGB},0.06)` }}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, letterSpacing: '0.04em', color: WC.muted2 }}>{DOW[d.getDay()]}</span>
|
||||
<div style={{ width: 30, height: 30, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: 3, flex: 'none', background: isToday ? WC.accent : 'transparent' }}>
|
||||
<span style={{ fontFamily: "'Newsreader',serif", fontSize: 18, fontWeight: 500, lineHeight: 1, color: isToday ? '#fff' : WC.ink }}>{d.getDate()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div ref={scrollRef} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{Array.from({ length: 24 }, (_, h) => (
|
||||
<div key={h} style={{ display: 'grid', gridTemplateColumns: '56px repeat(7,1fr)', borderBottom: `1px solid rgba(${WC.inkRGB},0.05)`, minHeight: ROW_H }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '5px 8px 0 0', fontFamily: "'JetBrains Mono',monospace", fontSize: 10, color: WC.muted2 }}>{hourLabel(h)}</div>
|
||||
{days.map((d, di) => {
|
||||
const runs = (occByDay.get(dayKey(d)) || []).filter((r) => r.at.getHours() === h);
|
||||
const isNow = sameDay(d, now) && now.getHours() === h;
|
||||
return (
|
||||
<div key={di} style={{ position: 'relative', borderLeft: `1px solid rgba(${WC.inkRGB},0.06)`, padding: '2px 4px', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{isNow && <>
|
||||
<div style={{ position: 'absolute', left: -4, top: nowFrac * ROW_H - 4, width: 8, height: 8, borderRadius: '50%', background: WC.accent, zIndex: 4 }} />
|
||||
<div style={{ position: 'absolute', left: 0, right: 0, top: nowFrac * ROW_H, height: 2, background: WC.accent, zIndex: 3 }} />
|
||||
</>}
|
||||
{runs.slice(0, 3).map((r, ri) => (
|
||||
<div key={ri} onClick={() => onSelect(r.wfId)} style={{ display: 'flex', alignItems: 'center', background: r.color, color: '#fff', borderRadius: 999, padding: '1px 7px', fontSize: 10, fontWeight: 600, cursor: 'pointer', lineHeight: 1.2, overflow: 'hidden' }}>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
||||
<span style={{ marginLeft: 'auto', paddingLeft: 6, opacity: 0.85, flex: 'none' }}>{miniTime(r.at)}</span>
|
||||
</div>
|
||||
))}
|
||||
{runs.length > 3 && (
|
||||
<span
|
||||
onClick={(e) => openDayPop(`${DOW[d.getDay()]} ${d.getDate()} · ${hourLabel(h)}`, runs, e)}
|
||||
style={{ ...moreStyle(WC), fontSize: 10 }}
|
||||
>{runs.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarView;
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useWC, WORKFLOW_PALETTE } from './uiKit';
|
||||
|
||||
// Small swatch button that opens a palette popover. Self-rendered (no portal)
|
||||
// so it survives the canvas compositor, same reasoning as RepeatField.
|
||||
const ColorSwatch: React.FC<{ value: string; onChange: (hex: string) => void; size?: number }> = ({ value, onChange, size = 14 }) => {
|
||||
const WC = useWC();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', flex: 'none' }}>
|
||||
<div
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title="Workflow color"
|
||||
style={{ width: size, height: size, borderRadius: 4, cursor: 'pointer', background: value, boxShadow: `0 0 0 1px rgba(${WC.inkRGB},0.14)` }}
|
||||
/>
|
||||
{open && (
|
||||
<div style={{ position: 'absolute', top: size + 11, left: 0, zIndex: 45, background: WC.paper, border: `1px solid ${WC.line2}`, borderRadius: WC.radius.lg, boxShadow: WC.shadow.md, padding: 9, display: 'flex', gap: 7 }}>
|
||||
{WORKFLOW_PALETTE.map((hex) => {
|
||||
const active = hex.toLowerCase() === value.toLowerCase();
|
||||
return (
|
||||
<div
|
||||
key={hex}
|
||||
onClick={() => { onChange(hex); setOpen(false); }}
|
||||
style={{ width: 18, height: 18, borderRadius: 5, cursor: 'pointer', background: hex, boxShadow: active ? `0 0 0 2px ${WC.paper}, 0 0 0 3.5px ${hex}` : `0 0 0 1px rgba(${WC.inkRGB},0.12)` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorSwatch;
|
||||
@@ -0,0 +1,236 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { createWorkflow, updateWorkflow } from '@/shared/state/workflowsSlice';
|
||||
import { sendMessage } from '@/shared/state/agentsSlice';
|
||||
import { defaultSchedule, stepsSignature, needsScheduleTestWarning } from '@/app/pages/Workflows/scheduleUtils';
|
||||
import { runWorkflowTest } from '@/app/pages/Workflows/runWorkflowTest';
|
||||
import AgentChat from '@/app/pages/AgentChat/AgentChat';
|
||||
import InlineEditableTitle from '@/app/components/InlineEditableTitle';
|
||||
import { Typewriter } from '@/app/components/feedback/Animated';
|
||||
import { useWC, FONT_SERIF, colorForWorkflow } from './uiKit';
|
||||
import ColorSwatch from './ColorSwatch';
|
||||
import { useEditAgentSession } from './useEditAgentSession';
|
||||
import { useWorkflowPatch } from './useWorkflowPatch';
|
||||
import ScheduleCard from './ScheduleCard';
|
||||
import StepsCard from './StepsCard';
|
||||
import SaveGuard from './SaveGuard';
|
||||
import type { AppNav } from './types';
|
||||
|
||||
// Short pill label for the clean cluster, plus the richer prompt actually sent
|
||||
// so the agent gets real detail. Spread across personas (work, money, research,
|
||||
// lifestyle, monitoring) so most people see one that fits. Keep labels similar
|
||||
// length so they cluster two-per-row.
|
||||
const NEW_CHIPS: Array<{ label: string; prompt: string }> = [
|
||||
{ label: 'Summarize my inbox daily', prompt: 'Each morning, summarize my inbox and draft replies to the important emails.' },
|
||||
{ label: 'Recap my weekly spending', prompt: 'Every Sunday, recap my spending, subscriptions, upcoming bills, and any weird charges.' },
|
||||
{ label: 'Digest of news in my field', prompt: 'Each week, give me a digest of the latest news and research in my field.' },
|
||||
{ label: 'Plan my weekend for me', prompt: 'Friday afternoon, plan my weekend from the weather and what\'s nearby.' },
|
||||
{ label: 'Watch a webpage for changes', prompt: 'Watch a webpage and alert me when it changes.' },
|
||||
];
|
||||
|
||||
const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
|
||||
const WC = useWC();
|
||||
const dispatch = useAppDispatch();
|
||||
const patch = useWorkflowPatch();
|
||||
const [draftId, setDraftId] = useState<string | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [guardOpen, setGuardOpen] = useState(false);
|
||||
// null = follow the auto open-on-first-message behavior; true/false = user override.
|
||||
const [paneManual, setPaneManual] = useState<boolean | null>(null);
|
||||
const created = useRef(false);
|
||||
const handedOff = useRef(false);
|
||||
|
||||
const workflow = useAppSelector((s) => (draftId ? s.workflows.items[draftId] : undefined));
|
||||
|
||||
// One unsaved draft per visit to "New". The backend hides unsaved drafts from
|
||||
// lists, so an abandoned one stays out of the way until GC.
|
||||
useEffect(() => {
|
||||
if (created.current) return;
|
||||
created.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
const wf = await dispatch(createWorkflow({ unsaved: true, title: 'Untitled workflow', steps: [], schedule: defaultSchedule() })).unwrap();
|
||||
setDraftId(wf.id);
|
||||
} catch { /* surfaced by the empty state */ }
|
||||
})();
|
||||
}, [dispatch]);
|
||||
|
||||
const sessionId = useEditAgentSession(draftId ?? '');
|
||||
const session = useAppSelector((s) => (sessionId ? s.agents.sessions[sessionId] : undefined));
|
||||
const agentBusy = session?.status === 'running' || session?.status === 'waiting_approval';
|
||||
const visibleMsgs = (session?.messages || []).filter((m) => !m.hidden).length;
|
||||
// The agent has actually said something back, not just the user's own message
|
||||
// sitting alone in the gap before the turn even starts. Gate the pane + handoff
|
||||
// on THIS: "any message exists" is true the instant you send, so it used to
|
||||
// fling you to the detail page before the agent ever replied.
|
||||
const agentReplied = (session?.messages || []).some((m) => m.role === 'assistant' && !m.hidden);
|
||||
// Landing state: the blank page (incl. before the session has loaded, so the
|
||||
// right pane starts closed rather than open-then-snap-shut). Gone the moment a
|
||||
// message lands or the agent starts working, so its "thinking" never shows here.
|
||||
const composeEmpty = !agentBusy && visibleMsgs === 0;
|
||||
// Open the pane only once the agent has fully answered (not mid-response, where
|
||||
// the chat is reflowing and the slide looks janky). Header toggle overrides it.
|
||||
const autoOpen = !agentBusy && agentReplied;
|
||||
const paneOpen = paneManual ?? autoOpen;
|
||||
|
||||
// Once it's revealed (in the sidebar) AND the agent has finished its first
|
||||
// answer, hand off to the detail page, after a beat so the right-pane open
|
||||
// animation plays here first. Same sticky edit session, so the chat carries over.
|
||||
useEffect(() => {
|
||||
if (handedOff.current || !draftId || !workflow) return;
|
||||
if (workflow.unsaved === false && autoOpen) {
|
||||
handedOff.current = true;
|
||||
setTimeout(() => nav.selectWorkflow(draftId), 480);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draftId, workflow?.unsaved, autoOpen]);
|
||||
|
||||
const sendChip = (text: string) => {
|
||||
if (!sessionId || !session) return;
|
||||
dispatch(sendMessage({ sessionId, prompt: text, mode: session.mode, model: session.model }));
|
||||
};
|
||||
|
||||
// The conversation started, so the workflow is real: reveal it under Workflows
|
||||
// (and hand off to its detail) right away. The title stays "Untitled workflow"
|
||||
// until the first step lands and the backend auto-names it (auto_named stays
|
||||
// true), so the name types in from the steps, not the raw prompt.
|
||||
const revealed = useRef(false);
|
||||
const firstUserMsg = (session?.messages || []).find((m) => m.role === 'user' && !m.hidden);
|
||||
useEffect(() => {
|
||||
if (revealed.current || !workflow || !firstUserMsg || workflow.unsaved === false) return;
|
||||
revealed.current = true;
|
||||
// Reveal immediately so it lands in the sidebar the moment you send; the
|
||||
// handoff to detail waits for the agent to finish (see effect above).
|
||||
dispatch(updateWorkflow({ id: workflow.id, patch: { unsaved: false } }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [firstUserMsg, workflow?.unsaved, dispatch]);
|
||||
|
||||
if (!workflow) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', background: WC.page }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ width: 14, height: 14, borderRadius: '50%', border: `2px solid rgba(${WC.inkRGB},0.15)`, borderTopColor: WC.accent, animation: 'os-spin 0.7s linear infinite' }} />
|
||||
<span style={{ fontFamily: "'Newsreader',serif", fontStyle: 'italic', fontSize: 14, color: WC.ink4 }}>Setting up your workflow…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tested = workflow.steps.length > 0 && stepsSignature(workflow.steps) === (workflow.tested_signature ?? '');
|
||||
|
||||
const doTest = async () => {
|
||||
if (testing || workflow.steps.length === 0) return;
|
||||
setTesting(true);
|
||||
try { await runWorkflowTest(workflow.id, workflow.steps, async () => {}); }
|
||||
finally { setTesting(false); }
|
||||
};
|
||||
|
||||
// No steps / no title is fine, you can save a bare workflow and fill it in
|
||||
// later. No If-Match: this is the user's own brand-new draft, so there's no
|
||||
// concurrent edit to guard against and a stale stamp shouldn't block the save.
|
||||
const finalizeSave = () => {
|
||||
dispatch(updateWorkflow({ id: workflow.id, patch: { unsaved: false } }));
|
||||
nav.selectWorkflow(workflow.id);
|
||||
};
|
||||
const onSave = () => {
|
||||
if (needsScheduleTestWarning(workflow)) { setGuardOpen(true); return; }
|
||||
finalizeSave();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: WC.page, position: 'relative' }}>
|
||||
<div style={{ flex: 'none', padding: '15px 28px', borderBottom: `1px solid rgba(${WC.inkRGB},0.06)`, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<ColorSwatch value={colorForWorkflow(workflow)} onChange={(hex) => patch(workflow, { color: hex })} size={15} />
|
||||
<InlineEditableTitle
|
||||
value={workflow.title || ''}
|
||||
onCommit={(t) => patch(workflow, { title: t, auto_named: false })}
|
||||
placeholder="Untitled workflow"
|
||||
sx={{ flex: 1, minWidth: 0, fontFamily: "'Newsreader',serif", fontSize: 21, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }}
|
||||
>
|
||||
<Typewriter value={workflow.title || 'Untitled workflow'} enabled={workflow.auto_named !== false}>
|
||||
{(t) => (
|
||||
<span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: "'Newsreader',serif", fontSize: 21, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }}>{t}</span>
|
||||
)}
|
||||
</Typewriter>
|
||||
</InlineEditableTitle>
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, letterSpacing: '0.06em', textTransform: 'uppercase', fontWeight: 500, color: WC.muted, background: `rgba(${WC.inkRGB},0.07)`, padding: '4px 10px', borderRadius: 999, flex: 'none' }}>Draft</span>
|
||||
<div
|
||||
onClick={() => setPaneManual(!paneOpen)}
|
||||
title={paneOpen ? 'Hide schedule & steps' : 'Show schedule & steps'}
|
||||
style={{ width: 28, height: 28, borderRadius: 7, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: paneOpen ? WC.ink3 : WC.muted, flex: 'none' }}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M14 4v16" /><path d={paneOpen ? 'M19 9l-2 3 2 3' : 'M17 9l2 3-2 3'} /></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', position: 'relative' }}>
|
||||
{sessionId
|
||||
? <AgentChat sessionId={sessionId} embedded autoFocus workflowEditId={workflow.id} />
|
||||
: <div style={{ flex: 1 }} />}
|
||||
{composeEmpty && (
|
||||
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 16, padding: '0 28px 96px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<div style={{ width: 11, height: 11, borderRadius: 3, background: WC.accent, flex: 'none' }} />
|
||||
<h2 style={{ margin: 0, fontFamily: FONT_SERIF, fontSize: 25, fontWeight: 500, fontStyle: 'italic', color: WC.ink }}>Describe the workflow to automate</h2>
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, color: WC.muted, maxWidth: 430, lineHeight: 1.55 }}>Tell me what you want this workflow to do. I'll turn it into steps you can run on a schedule, and you can tweak anything as we go.</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 9, justifyContent: 'center', width: '100%', maxWidth: 440, marginTop: 6, pointerEvents: 'auto' }}>
|
||||
{NEW_CHIPS.map((c) => (
|
||||
<button
|
||||
key={c.label}
|
||||
onClick={() => sendChip(c.prompt)}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = `rgba(${WC.inkRGB},0.1)`; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = `rgba(${WC.inkRGB},0.06)`; }}
|
||||
style={{ background: `rgba(${WC.inkRGB},0.06)`, border: `1px solid rgba(${WC.inkRGB},0.08)`, borderRadius: 999, padding: '8px 16px', fontSize: 12.5, color: WC.ink3, cursor: 'pointer', whiteSpace: 'nowrap', transition: 'background .15s' }}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{guardOpen && (
|
||||
<SaveGuard
|
||||
title={workflow.title || 'this workflow'}
|
||||
onClose={() => setGuardOpen(false)}
|
||||
onSaveAnyway={() => { setGuardOpen(false); finalizeSave(); }}
|
||||
onRunTest={() => { setGuardOpen(false); doTest(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden on the blank landing page; opens with a smooth width/fade once
|
||||
the conversation starts. */}
|
||||
<div style={{ width: paneOpen ? 344 : 0, flex: 'none', overflow: 'hidden', background: WC.rail, transition: 'width .4s cubic-bezier(.4,0,.2,1)' }}>
|
||||
<div style={{ width: 344, height: '100%', borderLeft: `1px solid ${WC.line}`, display: 'flex', flexDirection: 'column', minHeight: 0, opacity: paneOpen ? 1 : 0, transition: 'opacity .4s ease' }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, padding: '18px 18px 22px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<ScheduleCard workflow={workflow} />
|
||||
<StepsCard workflow={workflow} />
|
||||
</div>
|
||||
<div style={{ flex: 'none', borderTop: `1px solid rgba(${WC.inkRGB},0.08)`, background: WC.rail, padding: '13px 18px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{!tested && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 7, fontSize: 11.5, lineHeight: 1.4, color: WC.muted }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={WC.warn} strokeWidth="2" style={{ flex: 'none', marginTop: 1 }}><circle cx="12" cy="12" r="9" /><path d="M12 8v5" /><path d="M12 16h.01" /></svg>
|
||||
<span>Not tested yet — a test run grants the tool access this workflow needs.</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 9 }}>
|
||||
<button onClick={doTest} disabled={testing || workflow.steps.length === 0} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, flex: 'none', padding: '10px 15px', borderRadius: 9, border: `1px solid rgba(${WC.inkRGB},0.14)`, background: WC.paper, color: testing || workflow.steps.length === 0 ? WC.muted2 : WC.ink, fontSize: 13, fontWeight: 600, cursor: testing || workflow.steps.length === 0 ? 'default' : 'pointer' }}>
|
||||
{testing
|
||||
? <div style={{ width: 12, height: 12, borderRadius: '50%', border: '2px solid rgba(140,133,122,0.3)', borderTopColor: WC.muted, animation: 'os-spin 0.7s linear infinite', flex: 'none' }} />
|
||||
: <div style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: `8px solid ${WC.accent}`, flex: 'none' }} />}
|
||||
<span>{testing ? 'Testing…' : tested ? 'Run again' : 'Test run'}</span>
|
||||
</button>
|
||||
<button onClick={onSave} style={{ flex: 1, background: WC.accent, color: '#fff', border: 'none', borderRadius: 9, padding: 10, fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Save workflow</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComposeView;
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { runWorkflowNow } from '@/shared/state/workflowsSlice';
|
||||
import { openWorkflowMonitor, setWorkflowsRunContext, clearWorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { stepsSignature, isScheduleActive } from '@/app/pages/Workflows/scheduleUtils';
|
||||
import { askRun } from './api';
|
||||
import AgentChat from '@/app/pages/AgentChat/AgentChat';
|
||||
import InlineEditableTitle from '@/app/components/InlineEditableTitle';
|
||||
import { Typewriter } from '@/app/components/feedback/Animated';
|
||||
import { useWC, colorForWorkflow, statusChip } from './uiKit';
|
||||
import { isRunning, runContextChip } from './model';
|
||||
import { useEditAgentSession } from './useEditAgentSession';
|
||||
import { useWorkflowPatch } from './useWorkflowPatch';
|
||||
import ScheduleCard from './ScheduleCard';
|
||||
import StepsCard from './StepsCard';
|
||||
import HistoryCard from './HistoryCard';
|
||||
import ColorSwatch from './ColorSwatch';
|
||||
import type { AppNav } from './types';
|
||||
|
||||
const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId }) => {
|
||||
const WC = useWC();
|
||||
const dispatch = useAppDispatch();
|
||||
const patch = useWorkflowPatch();
|
||||
const workflow = useAppSelector((s) => s.workflows.items[workflowId]);
|
||||
const active = useAppSelector((s) => s.workflows.active);
|
||||
const sessionId = useEditAgentSession(workflowId);
|
||||
const detailRuns = useAppSelector((s) => s.workflows.runs[workflowId]);
|
||||
const runContext = useAppSelector((s) => s.dashboardLayout.workflowsRunContext);
|
||||
// When you Run now from this chat, attach that run as a context chip once it
|
||||
// finishes, so the next question rides on its transcript (removable, no popup).
|
||||
const autoCtxRunId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const rid = autoCtxRunId.current;
|
||||
if (!rid) return;
|
||||
const r = (detailRuns || []).find((x) => x.id === rid);
|
||||
if (!r || r.status === 'running') return;
|
||||
autoCtxRunId.current = null;
|
||||
if (workflow) dispatch(setWorkflowsRunContext(runContextChip(workflow, r)));
|
||||
}, [detailRuns, workflowId, dispatch, workflow]);
|
||||
|
||||
if (!workflow) return <div style={{ flex: 1, background: WC.page }} />;
|
||||
|
||||
const running = isRunning(workflow, active);
|
||||
const enabled = isScheduleActive(workflow.schedule);
|
||||
const status = running ? 'running' : enabled ? 'success' : 'paused';
|
||||
const statusText = running ? 'Running' : enabled ? 'Active' : 'Paused';
|
||||
|
||||
const runNow = () => {
|
||||
if (running) return;
|
||||
dispatch(runWorkflowNow({ id: workflow.id, signature: stepsSignature(workflow.steps) }))
|
||||
.unwrap().then((res) => { autoCtxRunId.current = res.run_id || null; }).catch(() => {});
|
||||
dispatch(openWorkflowMonitor({ workflowId: workflow.id }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: WC.page }}>
|
||||
<div style={{ flex: 'none', padding: '20px 28px 16px', borderBottom: `1px solid rgba(${WC.inkRGB},0.06)` }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 13 }}>
|
||||
<ColorSwatch value={colorForWorkflow(workflow)} onChange={(hex) => patch(workflow, { color: hex })} size={14} />
|
||||
<InlineEditableTitle
|
||||
value={workflow.title || ''}
|
||||
onCommit={(t) => patch(workflow, { title: t, auto_named: false })}
|
||||
placeholder="Untitled workflow"
|
||||
sx={{ flex: 1, minWidth: 0, fontFamily: "'Newsreader',serif", fontSize: 25, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }}
|
||||
>
|
||||
<Typewriter value={workflow.title || 'Untitled workflow'} enabled={workflow.auto_named !== false}>
|
||||
{(t) => (
|
||||
<span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: "'Newsreader',serif", fontSize: 25, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }}>{t}</span>
|
||||
)}
|
||||
</Typewriter>
|
||||
</InlineEditableTitle>
|
||||
<span style={statusChip(status, WC)}>{statusText}</span>
|
||||
<button onClick={runNow} disabled={running} style={{ display: 'flex', alignItems: 'center', gap: 8, background: running ? WC.inset : WC.ink, color: running ? WC.muted : WC.paper, border: 'none', borderRadius: 9, padding: '8px 15px', fontSize: 13, fontWeight: 600, cursor: running ? 'default' : 'pointer', flex: 'none' }}>
|
||||
{running
|
||||
? <div style={{ width: 12, height: 12, borderRadius: '50%', border: '2px solid rgba(140,133,122,0.3)', borderTopColor: WC.muted, animation: 'os-spin 0.7s linear infinite', flex: 'none' }} />
|
||||
: <div style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: `8px solid ${WC.paper}`, flex: 'none' }} />}
|
||||
<span>{running ? 'Running…' : 'Run'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{workflow.description && <div style={{ fontSize: 13.5, color: WC.muted, marginTop: 7, paddingLeft: 27 }}>{workflow.description}</div>}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{sessionId
|
||||
? <AgentChat
|
||||
sessionId={sessionId}
|
||||
embedded
|
||||
workflowEditId={workflow.id}
|
||||
runContext={runContext?.workflowId === workflow.id ? runContext : undefined}
|
||||
onClearRunContext={() => dispatch(clearWorkflowsRunContext())}
|
||||
onSendRunQuestion={(prompt, runId) => askRun(workflow.id, { runId, prompt }).then((ok) => { if (!ok) throw new Error('ask-run failed'); })}
|
||||
/>
|
||||
: <div style={{ flex: 1 }} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 344, flex: 'none', borderLeft: `1px solid ${WC.line}`, background: WC.rail, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, padding: '18px 18px 22px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<ScheduleCard workflow={workflow} />
|
||||
<StepsCard workflow={workflow} />
|
||||
<HistoryCard workflowId={workflow.id} title={workflow.title} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailView;
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { fetchRuns } from '@/shared/state/workflowsSlice';
|
||||
import { openWorkflowMonitor } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { useWC, FONT_SERIF, statusChip, statusDot, statusLabel } from './uiKit';
|
||||
import { toRunRow, whenText } from './model';
|
||||
|
||||
const HistoryCard: React.FC<{ workflowId: string; title: string }> = ({ workflowId, title }) => {
|
||||
const WC = useWC();
|
||||
const dispatch = useAppDispatch();
|
||||
const runs = useAppSelector((s) => s.workflows.runs[workflowId]);
|
||||
|
||||
useEffect(() => { dispatch(fetchRuns(workflowId)); }, [workflowId, dispatch]);
|
||||
|
||||
const rows = (runs || []).slice(0, 8).map((r) => toRunRow(r, title));
|
||||
const now = new Date();
|
||||
|
||||
return (
|
||||
<div style={{ background: WC.paper, border: `1px solid rgba(${WC.inkRGB},0.08)`, borderRadius: WC.radius.lg, padding: 16 }}>
|
||||
<div style={{ fontFamily: FONT_SERIF, fontSize: 16, fontWeight: 500, color: WC.ink, marginBottom: 12 }}>History</div>
|
||||
{rows.length === 0 && <div style={{ fontSize: 12.5, color: WC.muted2 }}>No runs yet.</div>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{rows.map((r) => (
|
||||
<div key={r.id} onClick={() => dispatch(openWorkflowMonitor({ workflowId, runId: r.id }))} title="Open this run" style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '9px 0', borderBottom: `1px solid rgba(${WC.inkRGB},0.05)`, cursor: 'pointer' }}>
|
||||
<div style={statusDot(r.status, WC)} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12.5, color: WC.ink2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.summary}</div>
|
||||
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, color: WC.muted2, marginTop: 2 }}>
|
||||
{whenText(r.when, now)}{r.durationText ? ` · ${r.durationText}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span style={statusChip(r.status, WC)}>{statusLabel(r.status)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryCard;
|
||||
@@ -0,0 +1,230 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { runMissedRuns, dismissMissedRuns } from '@/shared/state/missedRunsSlice';
|
||||
import { openWorkflowMonitor } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { useCalendarOccurrences } from './useCalendarOccurrences';
|
||||
import { colorForWorkflow, useWC, statusChip, statusDot } from './uiKit';
|
||||
import { clockOf, whenText } from './model';
|
||||
import WorkflowTitle from './WorkflowTitle';
|
||||
import type { AppNav } from './types';
|
||||
|
||||
interface ComingRun { wfId: string; title: string; time: string; sortKey: number; steps: number; color: string; }
|
||||
interface ComingGroup { key: string; dayNum: number; dow: string; runs: ComingRun[]; countLabel: string; }
|
||||
const COMING_CAP = 3;
|
||||
|
||||
const HomeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
|
||||
const WC = useWC();
|
||||
const dispatch = useAppDispatch();
|
||||
const items = useAppSelector((s) => s.workflows.items);
|
||||
const allRuns = useAppSelector((s) => s.workflows.allRuns);
|
||||
const active = useAppSelector((s) => s.workflows.active);
|
||||
const missed = useAppSelector((s) => s.missedRuns.items);
|
||||
const [missedExpanded, setMissedExpanded] = useState(false);
|
||||
const [expandedDays, setExpandedDays] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const now = new Date();
|
||||
const todayLabel = now.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' });
|
||||
// Animate only AI-driven renames; a user-renamed workflow (auto_named false) snaps.
|
||||
const animateOf = (wfId: string) => items[wfId]?.auto_named !== false;
|
||||
|
||||
const ongoing = useMemo(() => active.map((a) => {
|
||||
const wf = items[a.workflow_id];
|
||||
const run = allRuns.find((r) => r.id === a.run_id);
|
||||
const total = wf?.steps.length || 0;
|
||||
const idx = run?.active_step_idx ?? 0;
|
||||
const pct = total > 0 ? Math.min(100, Math.round(((idx + 1) / total) * 100)) : 10;
|
||||
return {
|
||||
wfId: a.workflow_id,
|
||||
title: wf?.title || a.title || 'Workflow',
|
||||
color: wf ? colorForWorkflow(wf) : WC.accent,
|
||||
stepLabel: total > 0 ? `Step ${Math.min(idx + 1, total)}/${total}` : 'Running',
|
||||
pct,
|
||||
nowText: run?.last_tool_label || 'Working…',
|
||||
clock: a.started_at ? clockOf(new Date(a.started_at)) : '',
|
||||
};
|
||||
}), [active, items, allRuns]);
|
||||
|
||||
// Fetch the 7-day window from the backend's recurrence engine (single source
|
||||
// of truth) instead of recomputing fire times in JS.
|
||||
const dayKey = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`;
|
||||
const { fromIso, toIso } = useMemo(() => {
|
||||
const from = new Date(now); from.setHours(0, 0, 0, 0);
|
||||
return { fromIso: from.toISOString(), toIso: new Date(from.getTime() + 7 * 86400000).toISOString() };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dayKey]);
|
||||
const { events } = useCalendarOccurrences(fromIso, toIso);
|
||||
const comingGroups = useMemo<ComingGroup[]>(() => {
|
||||
const from = new Date(now); from.setHours(0, 0, 0, 0);
|
||||
const byDay = new Map<string, ComingRun[]>();
|
||||
for (const e of events) {
|
||||
if (e.at.getTime() < now.getTime()) continue; // upcoming only
|
||||
const wf = items[e.workflowId];
|
||||
if (!wf || wf.unsaved) continue;
|
||||
const f = e.at;
|
||||
const key = `${f.getFullYear()}-${f.getMonth()}-${f.getDate()}`;
|
||||
const arr = byDay.get(key) || [];
|
||||
arr.push({ wfId: wf.id, title: wf.title || 'Untitled', time: clockOf(f), sortKey: f.getTime(), steps: wf.steps.length, color: colorForWorkflow(wf) });
|
||||
byDay.set(key, arr);
|
||||
}
|
||||
const groups: ComingGroup[] = [];
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
const d = new Date(from.getTime() + i * 86400000);
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
const runs = (byDay.get(key) || []).sort((a, b) => a.sortKey - b.sortKey);
|
||||
if (runs.length) groups.push({ key, dayNum: d.getDate(), dow: d.toLocaleDateString([], { weekday: 'short' }), runs, countLabel: runs.length === 1 ? '1 run' : `${runs.length} runs` });
|
||||
}
|
||||
return groups;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [events, items]);
|
||||
|
||||
const recents = useMemo(() => allRuns.slice(0, 8).map((r) => ({
|
||||
id: r.id,
|
||||
wfId: r.workflow_id,
|
||||
title: items[r.workflow_id]?.title || 'Workflow',
|
||||
status: r.status,
|
||||
summary: r.error || r.last_tool_label || (r.status === 'success' ? 'Completed' : r.status),
|
||||
when: r.started_at ? new Date(r.started_at) : null,
|
||||
})), [allRuns, items]);
|
||||
|
||||
const missedVisible = missedExpanded ? missed : missed.slice(0, 3);
|
||||
const reRunAll = () => { if (missed.length) dispatch(runMissedRuns(missed.map((m) => m.id))); };
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: WC.page }}>
|
||||
<div style={{ flex: 'none', padding: '22px 30px 14px', borderBottom: `1px solid rgba(${WC.inkRGB},0.06)` }}>
|
||||
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, letterSpacing: '0.06em', textTransform: 'uppercase', color: WC.muted2 }}>{todayLabel}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, padding: '0 30px 32px' }}>
|
||||
{ongoing.length > 0 && (
|
||||
<div style={{ marginTop: 22 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 11 }}>
|
||||
<span style={{ fontFamily: "'Newsreader',serif", fontSize: 18, fontWeight: 500, color: WC.ink }}>Ongoing runs</span>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', background: WC.accent, animation: 'os-pulse 1.1s ease-in-out infinite' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{ongoing.map((o) => (
|
||||
<div key={o.wfId} onClick={() => dispatch(openWorkflowMonitor({ workflowId: o.wfId }))} style={{ display: 'flex', alignItems: 'center', gap: 13, background: WC.raised, border: `1px solid rgba(${WC.inkRGB},0.10)`, borderRadius: WC.radius.md, padding: '11px 15px', cursor: 'pointer' }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: o.color, flex: 'none' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<WorkflowTitle value={o.title} animate={animateOf(o.wfId)}>
|
||||
{(t) => <span style={{ fontSize: 14, fontWeight: 600, color: WC.ink, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t}</span>}
|
||||
</WorkflowTitle>
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, color: WC.muted2, flex: 'none' }}>{o.stepLabel}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 999, background: WC.inset, overflow: 'hidden', marginTop: 7 }}>
|
||||
<div style={{ width: `${o.pct}%`, height: '100%', borderRadius: 999, background: o.color, transition: 'width .4s ease' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: WC.muted, marginTop: 6, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{o.nowText}</div>
|
||||
</div>
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11.5, color: WC.ink3, flex: 'none' }}>{o.clock}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: WC.accent, fontSize: 12, fontWeight: 600, flex: 'none' }}>
|
||||
<span>Watch</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"><path d="M9 6l6 6-6 6" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 26, marginTop: 22 }}>
|
||||
{missed.length > 0 && (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 11 }}>
|
||||
<span style={{ fontFamily: "'Newsreader',serif", fontSize: 18, fontWeight: 500, color: WC.ink }}>Missed</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: WC.danger, background: WC.dangerBg, padding: '2px 8px', borderRadius: 999 }}>{missed.length}</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={reRunAll} style={{ background: 'transparent', border: `1px solid rgba(${WC.inkRGB},0.14)`, borderRadius: 8, padding: '6px 12px', fontSize: 12, fontWeight: 600, color: WC.ink3, cursor: 'pointer' }}>Re-run all</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, ...(missedExpanded ? { maxHeight: 306, overflowY: 'auto', paddingRight: 4 } : {}) }}>
|
||||
{missedVisible.map((m) => (
|
||||
<div key={m.id} style={{ display: 'flex', alignItems: 'center', gap: 12, background: WC.raised, border: '1px solid rgba(194,72,58,0.20)', borderRadius: WC.radius.md, padding: '10px 14px' }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: WC.danger, flex: 'none' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<WorkflowTitle value={m.workflow_title} animate={animateOf(m.workflow_id)}>
|
||||
{(t) => <div style={{ fontSize: 13.5, fontWeight: 600, color: WC.ink, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t}</div>}
|
||||
</WorkflowTitle>
|
||||
<div style={{ fontSize: 11.5, color: WC.muted, marginTop: 1 }}>Missed while the app was closed</div>
|
||||
</div>
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, color: WC.muted2, flex: 'none' }}>{whenText(new Date(m.scheduled_for), now)}</span>
|
||||
<button onClick={() => dispatch(runMissedRuns([m.id]))} style={{ background: WC.ink, color: WC.paper, border: 'none', borderRadius: 8, padding: '6px 13px', fontSize: 12, fontWeight: 600, cursor: 'pointer', flex: 'none' }}>Re-run</button>
|
||||
<div onClick={() => dispatch(dismissMissedRuns([m.id]))} title="Dismiss" style={{ width: 26, height: 26, borderRadius: 7, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.faint, flex: 'none' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{missed.length > 3 && (
|
||||
<button onClick={() => setMissedExpanded((v) => !v)} style={{ marginTop: 10, background: 'transparent', border: 'none', color: WC.danger, fontSize: 12.5, fontWeight: 600, cursor: 'pointer', padding: '4px 2px', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>{missedExpanded ? 'Show less' : `Show all ${missed.length} missed`}</span>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" style={{ transform: missedExpanded ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}><path d="M6 9l6 6 6-6" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: "'Newsreader',serif", fontSize: 18, fontWeight: 500, color: WC.ink, marginBottom: 12 }}>Coming up</div>
|
||||
{comingGroups.length === 0 && (
|
||||
<div style={{ fontSize: 13, color: WC.muted }}>Nothing scheduled in the next 7 days.</div>
|
||||
)}
|
||||
<div style={{ maxHeight: 340, overflowY: 'auto', overflowX: 'hidden', paddingRight: 6 }}>
|
||||
{comingGroups.map((g) => (
|
||||
<div key={g.key} style={{ display: 'flex', gap: 18, padding: '4px 0 16px' }}>
|
||||
<div style={{ width: 62, flex: 'none', textAlign: 'right', paddingTop: 2 }}>
|
||||
<div style={{ fontFamily: "'Newsreader',serif", fontSize: 26, fontWeight: 500, color: WC.ink, lineHeight: 1 }}>{g.dayNum}</div>
|
||||
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, letterSpacing: '0.04em', textTransform: 'uppercase', color: WC.muted2, marginTop: 4 }}>{g.dow}</div>
|
||||
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, color: WC.faint, marginTop: 3 }}>{g.countLabel}</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{(expandedDays.has(g.key) ? g.runs : g.runs.slice(0, COMING_CAP)).map((r, i) => (
|
||||
<div key={`${r.wfId}-${i}`} onClick={() => nav.selectWorkflow(r.wfId)} style={{ display: 'flex', alignItems: 'center', gap: 13, background: WC.raised, border: `1px solid rgba(${WC.inkRGB},0.08)`, borderRadius: WC.radius.md, padding: '12px 15px', cursor: 'pointer' }}>
|
||||
<div style={{ width: 3, height: 30, borderRadius: 3, background: r.color, flex: 'none' }} />
|
||||
<WorkflowTitle value={r.title} animate={animateOf(r.wfId)}>
|
||||
{(t) => <span style={{ fontSize: 14, fontWeight: 600, color: WC.ink, flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t}</span>}
|
||||
</WorkflowTitle>
|
||||
<span style={{ fontSize: 12, color: WC.muted }}>{r.steps} steps</span>
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 12, color: WC.ink3, minWidth: 74, textAlign: 'right' }}>{r.time}</span>
|
||||
</div>
|
||||
))}
|
||||
{g.runs.length > COMING_CAP && (
|
||||
<button onClick={() => setExpandedDays((prev) => { const next = new Set(prev); if (next.has(g.key)) next.delete(g.key); else next.add(g.key); return next; })} style={{ alignSelf: 'flex-start', background: 'transparent', border: 'none', color: WC.muted, fontSize: 12.5, fontWeight: 600, cursor: 'pointer', padding: '3px 2px', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>{expandedDays.has(g.key) ? 'Show less' : `${g.runs.length - COMING_CAP} more`}</span>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" style={{ transform: expandedDays.has(g.key) ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}><path d="M6 9l6 6 6-6" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<div style={{ fontFamily: "'Newsreader',serif", fontSize: 18, fontWeight: 500, color: WC.ink, marginBottom: 12 }}>Recents</div>
|
||||
{recents.length === 0 && <div style={{ fontSize: 13, color: WC.muted }}>No runs yet.</div>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{recents.map((r) => (
|
||||
<div key={r.id} onClick={() => nav.selectWorkflow(r.wfId)} style={{ display: 'flex', alignItems: 'center', gap: 13, padding: '11px 4px', borderBottom: `1px solid rgba(${WC.inkRGB},0.05)`, cursor: 'pointer' }}>
|
||||
<div style={statusDot(r.status, WC)} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<WorkflowTitle value={r.title} animate={animateOf(r.wfId)}>
|
||||
{(t) => <div style={{ fontSize: 13.5, fontWeight: 600, color: WC.ink }}>{t}</div>}
|
||||
</WorkflowTitle>
|
||||
<div style={{ fontSize: 12, color: WC.muted, marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.summary}</div>
|
||||
</div>
|
||||
<div style={{ width: 72, display: 'flex', justifyContent: 'flex-end', flex: 'none' }}>
|
||||
<span style={statusChip(r.status, WC)}>{r.status === 'failure' ? 'Failed' : r.status === 'success' ? 'Success' : r.status}</span>
|
||||
</div>
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, color: WC.muted2, minWidth: 96, textAlign: 'right' }}>{whenText(r.when, now)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeView;
|
||||
@@ -0,0 +1,135 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { deleteWorkflow } from '@/shared/state/workflowsSlice';
|
||||
import { isScheduleActive, describeSchedule } from '@/app/pages/Workflows/scheduleUtils';
|
||||
import { colorForWorkflow, useWC } from './uiKit';
|
||||
import WorkflowTitle from './WorkflowTitle';
|
||||
import type { AppNav } from './types';
|
||||
|
||||
const navBase: CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 9, padding: '6px 9px',
|
||||
borderRadius: 8, cursor: 'pointer', fontSize: 13.5,
|
||||
};
|
||||
|
||||
const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
|
||||
const WC = useWC();
|
||||
const dispatch = useAppDispatch();
|
||||
const items = useAppSelector((s) => s.workflows.items);
|
||||
const trashCount = useAppSelector((s) => s.workflows.deleted.length);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const workflows = useMemo(() => Object.values(items)
|
||||
.filter((w) => !w.unsaved)
|
||||
.sort((a, b) => (b.updated_at || b.created_at).localeCompare(a.updated_at || a.created_at)), [items]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return workflows;
|
||||
return workflows.filter((w) => w.title.toLowerCase().includes(q));
|
||||
}, [workflows, query]);
|
||||
|
||||
const activeCount = workflows.filter((w) => isScheduleActive(w.schedule)).length;
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
// Soft-delete: moves to Trash (recoverable), so no scary confirm.
|
||||
dispatch(deleteWorkflow(id));
|
||||
if (nav.selectedId === id) nav.goHome();
|
||||
};
|
||||
|
||||
const homeStyle: CSSProperties = nav.mode === 'home'
|
||||
? { ...navBase, background: WC.selBg, color: WC.ink, fontWeight: 600 }
|
||||
: { ...navBase, color: WC.ink3 };
|
||||
const calStyle: CSSProperties = nav.mode === 'calendar'
|
||||
? { ...navBase, background: WC.selBg, color: WC.ink, fontWeight: 600 }
|
||||
: { ...navBase, color: WC.ink3 };
|
||||
const newStyle: CSSProperties = nav.mode === 'new'
|
||||
? { ...navBase, background: WC.accent, color: '#fff', fontWeight: 600 }
|
||||
: { ...navBase, color: WC.accent, fontWeight: 600 };
|
||||
const trashStyle: CSSProperties = nav.mode === 'trash'
|
||||
? { ...navBase, background: WC.selBg, color: WC.ink, fontWeight: 600 }
|
||||
: { ...navBase, color: WC.ink3 };
|
||||
|
||||
return (
|
||||
<div style={{ width: 248, flex: 'none', borderRight: `1px solid ${WC.line}`, background: WC.rail, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ padding: '14px 12px 10px', flex: 'none' }}>
|
||||
<div style={{ height: 30, borderRadius: 8, background: WC.paper, border: `1px solid rgba(${WC.inkRGB},0.08)`, display: 'flex', alignItems: 'center', gap: 7, padding: '0 9px', color: WC.muted, fontSize: 12.5 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4-4" /></svg>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
style={{ flex: 1, border: 'none', background: 'transparent', fontSize: 12.5, color: WC.ink }}
|
||||
/>
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, opacity: 0.6 }}>⌘K</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '4px 8px', flex: 'none', display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<div onClick={nav.goHome} style={homeStyle}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9"><path d="M3 11l9-7 9 7M5 10v9h5v-6h4v6h5v-9" /></svg>
|
||||
<span>Home</span>
|
||||
</div>
|
||||
<div onClick={nav.goCalendar} style={calStyle}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9"><rect x="3" y="4.5" width="18" height="16" rx="2.5" /><path d="M3 9h18M8 2.5v4M16 2.5v4" /></svg>
|
||||
<span>Calendar</span>
|
||||
</div>
|
||||
<div onClick={nav.goNew} style={newStyle}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14M5 12h14" /></svg>
|
||||
<span>New Workflow</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '14px 16px 6px', flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, letterSpacing: '0.08em', textTransform: 'uppercase', color: WC.muted2 }}>Workflows</span>
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, color: WC.muted2 }}>{activeCount}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 8px', minHeight: 0 }}>
|
||||
{filtered.map((w) => {
|
||||
const active = isScheduleActive(w.schedule);
|
||||
const isSel = nav.mode === 'detail' && w.id === nav.selectedId;
|
||||
return (
|
||||
<div
|
||||
key={w.id}
|
||||
onClick={() => nav.selectWorkflow(w.id)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '5px 9px', borderRadius: 8, cursor: 'pointer', background: isSel ? WC.selBg : 'transparent' }}
|
||||
>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', flex: 'none', background: colorForWorkflow(w), opacity: active ? 1 : 0.35 }} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<WorkflowTitle value={w.title} animate={w.auto_named !== false}>
|
||||
{(t) => <div style={{ fontSize: 13.5, fontWeight: 600, color: active ? WC.ink : WC.muted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t}</div>}
|
||||
</WorkflowTitle>
|
||||
<div style={{ fontSize: 11, color: WC.muted2, marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{active ? describeSchedule(w.schedule) : 'Paused'}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(w.id); }}
|
||||
style={{ width: 22, height: 22, borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.faint, flex: 'none' }}
|
||||
aria-label="Move to trash"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9"><path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ padding: '18px 10px', fontSize: 12.5, color: WC.muted2 }}>No workflows yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 'none', borderTop: `1px solid ${WC.line}`, padding: '8px' }}>
|
||||
<div onClick={nav.goTrash} style={trashStyle}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13" /></svg>
|
||||
<span>Trash</span>
|
||||
{trashCount > 0 && (
|
||||
<span style={{ marginLeft: 'auto', fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, color: WC.muted2 }}>{trashCount}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeftRail;
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useWC } from './uiKit';
|
||||
|
||||
// Combobox for the run limit: pick a preset OR type any count. Self-rendered
|
||||
// (no native <select>, no portal) because a native popup over the canvas card
|
||||
// is a separate compositor layer and gets dismissed before you can click it.
|
||||
const OPTIONS: Array<{ label: string; val: number | null }> = [
|
||||
{ label: 'Forever', val: null },
|
||||
{ label: 'Once', val: 1 },
|
||||
{ label: '3 times', val: 3 },
|
||||
{ label: '10 times', val: 10 },
|
||||
];
|
||||
|
||||
const RepeatField: React.FC<{ value: number | null; onChange: (n: number | null) => void }> = ({ value, onChange }) => {
|
||||
const WC = useWC();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); setEditing(false); }
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [open]);
|
||||
|
||||
const display = value == null ? '' : `${value} time${value === 1 ? '' : 's'}`;
|
||||
|
||||
const commit = (raw: string) => {
|
||||
const digits = raw.replace(/[^0-9]/g, '').slice(0, 4);
|
||||
setDraft(digits);
|
||||
onChange(digits === '' ? null : Math.max(1, parseInt(digits, 10)));
|
||||
};
|
||||
|
||||
const pick = (val: number | null) => {
|
||||
onChange(val);
|
||||
setDraft(val == null ? '' : String(val));
|
||||
setEditing(false);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', width: 134 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', background: WC.raised, border: `1px solid ${open ? WC.accent : `rgba(${WC.inkRGB},0.12)`}`, borderRadius: 8, height: 32, padding: '0 2px 0 9px' }}>
|
||||
<input
|
||||
value={editing ? draft : display}
|
||||
placeholder="Forever"
|
||||
inputMode="numeric"
|
||||
onFocus={() => { setEditing(true); setDraft(value == null ? '' : String(value)); setOpen(true); }}
|
||||
onChange={(e) => commit(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 0, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, color: value == null && !editing ? WC.muted : WC.ink, fontFamily: "'JetBrains Mono',monospace" }}
|
||||
/>
|
||||
<button
|
||||
onMouseDown={(e) => { e.preventDefault(); setOpen((o) => !o); }}
|
||||
style={{ border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', padding: 5, color: WC.muted }}
|
||||
aria-label="Repeat options"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms ease-in-out' }}><path d="M6 9l6 6 6-6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div style={{ position: 'absolute', top: 37, left: 0, right: 0, background: WC.raised, border: `1px solid ${WC.line}`, borderRadius: WC.radius.md, boxShadow: WC.shadow.md, padding: 4, zIndex: 40 }}>
|
||||
{OPTIONS.map((o) => {
|
||||
const active = (o.val == null && value == null) || o.val === value;
|
||||
return (
|
||||
<div
|
||||
key={o.label}
|
||||
onMouseDown={(e) => { e.preventDefault(); pick(o.val); }}
|
||||
style={{ padding: '7px 9px', borderRadius: 6, fontSize: 13, cursor: 'pointer', color: WC.ink, background: active ? WC.selBg : 'transparent', fontWeight: active ? 600 : 400 }}
|
||||
>
|
||||
{o.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RepeatField;
|
||||
@@ -0,0 +1,261 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import AgentChat from '@/app/pages/AgentChat/AgentChat';
|
||||
import { fetchRuns, controlWorkflowRun } from '@/shared/state/workflowsSlice';
|
||||
import type { Workflow, WorkflowRun } from '@/shared/state/workflowsSlice';
|
||||
import {
|
||||
bringToFront, closeWorkflowMonitor, setWorkflowsMonitorPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardType } from '@/shared/state/dashboardLayoutSlice';
|
||||
import WorkflowTitle from './WorkflowTitle';
|
||||
|
||||
type StepState = 'done' | 'running' | 'failed' | 'pending';
|
||||
const DRAG_THRESHOLD = 3;
|
||||
|
||||
function fmtClock(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms < 0) ms = 0;
|
||||
const s = Math.floor(ms / 1000);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function kindLabel(run: WorkflowRun | null): string {
|
||||
if (!run) return 'RUN';
|
||||
if (run.triggered_by === 'manual') return 'MANUAL RUN';
|
||||
if (run.triggered_by === 'retry') return 'RE-RUN';
|
||||
return 'SCHEDULED RUN';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workflow: Workflow;
|
||||
cardX: number;
|
||||
cardY: number;
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
cardZOrder: number;
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
onDragStart: (id: string, type: CardType) => void;
|
||||
onDragMove: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
|
||||
onDragEnd: (dx: number, dy: number, didDrag: boolean) => void;
|
||||
}
|
||||
|
||||
// The live run view, a real canvas card (standard claudeTokens chrome) spawned
|
||||
// beside the Workflows window. The orange connector back to the window is drawn
|
||||
// by the shared TetherLayer, same mechanism as an agent spinning up a browser.
|
||||
const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHeight, cardZOrder, zoom, panX, panY, onDragStart, onDragMove, onDragEnd }) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const runs = useAppSelector((s) => s.workflows.runs[workflow.id]);
|
||||
const allRuns = useAppSelector((s) => s.workflows.allRuns);
|
||||
const monitorRunId = useAppSelector((s) => s.dashboardLayout.workflowsMonitorRunId);
|
||||
const [nowTick, setNowTick] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => { dispatch(fetchRuns(workflow.id)); }, [workflow.id, dispatch]);
|
||||
|
||||
const panRef = useRef({ panX, panY });
|
||||
panRef.current = { panX, panY };
|
||||
const zoomRef = useRef(zoom);
|
||||
zoomRef.current = zoom;
|
||||
const dragState = useRef<{ sx: number; sy: number; ox: number; oy: number; spx: number; spy: number } | null>(null);
|
||||
const didDrag = useRef(false);
|
||||
const [localPos, setLocalPos] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const onHeaderDown = useCallback((e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.closest('button, [role="button"]')) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
dispatch(bringToFront({ id: 'workflows-monitor', type: 'workflows-monitor' }));
|
||||
dragState.current = { sx: e.clientX, sy: e.clientY, ox: cardX, oy: cardY, spx: panRef.current.panX, spy: panRef.current.panY };
|
||||
didDrag.current = false;
|
||||
onDragStart('workflows-monitor', 'workflows-monitor');
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [cardX, cardY, dispatch, onDragStart]);
|
||||
|
||||
const onHeaderMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const rdx = e.clientX - dragState.current.sx;
|
||||
const rdy = e.clientY - dragState.current.sy;
|
||||
if (!didDrag.current && Math.sqrt(rdx * rdx + rdy * rdy) < DRAG_THRESHOLD) return;
|
||||
didDrag.current = true;
|
||||
const z = zoomRef.current;
|
||||
const pdx = (panRef.current.panX - dragState.current.spx) / z;
|
||||
const pdy = (panRef.current.panY - dragState.current.spy) / z;
|
||||
const dx = rdx / z - pdx;
|
||||
const dy = rdy / z - pdy;
|
||||
setLocalPos({ x: dragState.current.ox + dx, y: dragState.current.oy + dy });
|
||||
// Feed the shared drag channel so the tether tracks live, same as cards.
|
||||
onDragMove(dx, dy, e.clientX, e.clientY);
|
||||
}, [onDragMove]);
|
||||
|
||||
const onHeaderUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const z = zoomRef.current;
|
||||
const pdx = (panRef.current.panX - dragState.current.spx) / z;
|
||||
const pdy = (panRef.current.panY - dragState.current.spy) / z;
|
||||
const dx = (e.clientX - dragState.current.sx) / z - pdx;
|
||||
const dy = (e.clientY - dragState.current.sy) / z - pdy;
|
||||
if (didDrag.current) {
|
||||
let nx = dragState.current.ox + dx;
|
||||
let ny = dragState.current.oy + dy;
|
||||
if (!e.shiftKey) { nx = Math.round(nx / 24) * 24; ny = Math.round(ny / 24) * 24; }
|
||||
dispatch(setWorkflowsMonitorPosition({ x: nx, y: ny }));
|
||||
}
|
||||
onDragEnd(dx, dy, didDrag.current);
|
||||
dragState.current = null;
|
||||
didDrag.current = false;
|
||||
setLocalPos(null);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [dispatch, onDragEnd]);
|
||||
|
||||
// A pinned run id (clicked from history) wins; otherwise follow the latest run.
|
||||
const run: WorkflowRun | null =
|
||||
(monitorRunId
|
||||
? (runs || []).find((r) => r.id === monitorRunId) || allRuns.find((r) => r.id === monitorRunId)
|
||||
: (runs && runs[0]) || allRuns.find((r) => r.workflow_id === workflow.id))
|
||||
|| null;
|
||||
const isRunning = run?.status === 'running';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRunning) return;
|
||||
const t = setInterval(() => setNowTick(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [isRunning]);
|
||||
|
||||
const steps = workflow.steps.filter((s) => s.enabled !== false && s.text && s.text.trim());
|
||||
const total = steps.length;
|
||||
const aidx = run?.active_step_idx ?? 0;
|
||||
const failed = run?.status === 'failure';
|
||||
const succeeded = run?.status === 'success' || run?.status === 'ran_late';
|
||||
const sessionId = run?.session_id || null;
|
||||
|
||||
const stepState = (i: number): StepState => {
|
||||
if (succeeded) return 'done';
|
||||
if (failed) return i < aidx ? 'done' : i === aidx ? 'failed' : 'pending';
|
||||
if (isRunning) return i < aidx ? 'done' : i === aidx ? 'running' : 'pending';
|
||||
return 'pending';
|
||||
};
|
||||
|
||||
const pct = total > 0
|
||||
? Math.round((succeeded ? total : Math.min(aidx + (isRunning ? 0.5 : 0), total)) / total * 100)
|
||||
: (isRunning ? 10 : 0);
|
||||
|
||||
const startedMs = run?.started_at ? new Date(run.started_at).getTime() : nowTick;
|
||||
const endMs = run?.finished_at ? new Date(run.finished_at).getTime() : nowTick;
|
||||
const clock = fmtClock((isRunning ? nowTick : endMs) - startedMs);
|
||||
|
||||
const headStatus = isRunning ? 'Running' : succeeded ? 'Done' : failed ? 'Failed' : 'Idle';
|
||||
const headColor = isRunning ? c.accent.primary : succeeded ? c.status.success : failed ? c.status.error : c.text.tertiary;
|
||||
const headBg = isRunning ? c.bg.secondary : succeeded ? c.status.successBg : failed ? c.status.errorBg : c.bg.secondary;
|
||||
|
||||
const progressLabel = isRunning
|
||||
? `Step ${Math.min(aidx + 1, total)} of ${total}`
|
||||
: succeeded ? `All ${total} steps complete` : failed ? `Failed at step ${Math.min(aidx + 1, total)}` : `${total} steps`;
|
||||
|
||||
const close = () => dispatch(closeWorkflowMonitor());
|
||||
const stopRun = () => { if (run?.id) dispatch(controlWorkflowRun({ runId: run.id, action: 'stop' })); };
|
||||
|
||||
const x = localPos?.x ?? cardX;
|
||||
const y = localPos?.y ?? cardY;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-select-type="workflows-monitor-card"
|
||||
data-select-id="workflows-monitor"
|
||||
onPointerDownCapture={() => dispatch(bringToFront({ id: 'workflows-monitor', type: 'workflows-monitor' }))}
|
||||
style={{
|
||||
position: 'absolute', left: x, top: y, width: cardWidth, height: cardHeight,
|
||||
background: c.bg.surface, border: `1px solid ${c.border.medium}`, borderRadius: c.radius.lg,
|
||||
boxShadow: c.shadow.lg, overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
zIndex: cardZOrder, contain: 'layout style',
|
||||
}}
|
||||
>
|
||||
{/* title bar (drag handle) */}
|
||||
<div
|
||||
onPointerDown={onHeaderDown}
|
||||
onPointerMove={onHeaderMove}
|
||||
onPointerUp={onHeaderUp}
|
||||
style={{ height: 44, flex: 'none', display: 'flex', alignItems: 'center', gap: 9, padding: '0 10px 0 14px', borderBottom: `1px solid ${c.border.subtle}`, background: c.bg.elevated, cursor: localPos ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }}
|
||||
>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: headColor, flex: 'none', ...(isRunning ? { animation: 'os-pulse 1.1s ease-in-out infinite' } : {}) }} />
|
||||
<WorkflowTitle value={workflow.title} animate={workflow.auto_named !== false}>
|
||||
{(t) => <span style={{ fontSize: 13.5, fontWeight: 600, color: c.text.primary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t}</span>}
|
||||
</WorkflowTitle>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: headColor, background: headBg, padding: '2px 9px', borderRadius: 999, flex: 'none' }}>{headStatus}</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11.5, color: c.text.tertiary, flex: 'none' }}>{clock}</span>
|
||||
<IconButton onClick={close} aria-label="Close" size="small" sx={{ flex: 'none', color: c.text.tertiary, '&:hover': { color: c.status.error, bgcolor: `${c.status.error}14` } }}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{/* progress subhead */}
|
||||
<div style={{ flex: 'none', padding: '14px 16px 13px', borderBottom: `1px solid ${c.border.subtle}` }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 9 }}>
|
||||
<span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 10, letterSpacing: '0.07em', color: c.text.tertiary }}>{kindLabel(run)}</span>
|
||||
<span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11, color: c.text.tertiary }}>{pct}%</span>
|
||||
</div>
|
||||
<div style={{ height: 5, borderRadius: 999, background: c.bg.secondary, overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', borderRadius: 999, background: failed ? c.status.error : c.accent.primary, transition: 'width .4s ease' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: c.text.secondary, marginTop: 8 }}>{progressLabel}</div>
|
||||
</div>
|
||||
|
||||
{/* workflow steps (bounded; the live chat fills the rest) */}
|
||||
<div style={{ flex: sessionId ? 'none' : 1, maxHeight: sessionId ? '40%' : undefined, overflowY: 'auto', minHeight: 0, padding: '12px 14px 14px', display: 'flex', flexDirection: 'column', gap: 7, borderBottom: sessionId ? `1px solid ${c.border.subtle}` : undefined }}>
|
||||
{steps.map((s, i) => {
|
||||
const st = stepState(i);
|
||||
const iconBg = st === 'done' ? c.status.success : st === 'failed' ? c.status.error : st === 'running' ? c.accent.primary : c.bg.secondary;
|
||||
return (
|
||||
<div key={s.id} style={{ background: st === 'running' ? c.bg.elevated : 'transparent', border: `1px solid ${st === 'running' ? c.border.medium : c.border.subtle}`, borderRadius: c.radius.md, padding: '10px 12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ width: 22, height: 22, borderRadius: '50%', flex: 'none', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{st === 'done' && <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3.2"><path d="M5 12l5 5L20 6" /></svg>}
|
||||
{st === 'running' && <div style={{ width: 10, height: 10, borderRadius: '50%', border: '2px solid rgba(255,255,255,0.5)', borderTopColor: '#fff', animation: 'os-spin 0.7s linear infinite' }} />}
|
||||
{st === 'failed' && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3.2"><path d="M6 6l12 12M18 6L6 18" /></svg>}
|
||||
</div>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 500, color: st === 'pending' ? c.text.tertiary : c.text.primary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.label || s.text.slice(0, 48)}</span>
|
||||
{st === 'done' && <span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 10, color: c.text.tertiary, flex: 'none' }}>done</span>}
|
||||
</div>
|
||||
{st === 'running' && run?.last_tool_label && (
|
||||
<div style={{ marginTop: 8, paddingLeft: 32, display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div style={{ width: 5, height: 5, borderRadius: '50%', background: c.accent.primary, marginTop: 6, flex: 'none' }} />
|
||||
<span style={{ fontSize: 11.5, color: c.text.secondary, lineHeight: 1.45 }}>{run.last_tool_label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{total === 0 && <div style={{ fontSize: 12.5, color: c.text.tertiary }}>This workflow has no runnable steps.</div>}
|
||||
</div>
|
||||
|
||||
{/* live transcript: read-only (prompts we send, agent responses, tool calls). Reuses AgentChat. */}
|
||||
{sessionId ? (
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<AgentChat sessionId={sessionId} embedded readOnly />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, color: c.text.tertiary }}>
|
||||
{isRunning ? 'Waiting for the run to start…' : failed ? 'This run failed before any agent ran.' : 'No agent chat for this run.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* footer only while live: Stop fully fails the in-flight run. Once it's
|
||||
done there are no buttons; the title-bar X closes the card. */}
|
||||
{isRunning && (
|
||||
<div style={{ flex: 'none', borderTop: `1px solid ${c.border.subtle}`, background: c.bg.elevated, padding: '11px 14px 13px', display: 'flex', gap: 9 }}>
|
||||
<button onClick={stopRun} style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, background: c.status.errorBg, border: `1px solid ${c.status.error}33`, borderRadius: c.radius.md, padding: 9, fontSize: 13, fontWeight: 600, color: c.status.error, cursor: 'pointer' }}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="2" /></svg>
|
||||
<span>Stop run</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RunMonitor;
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { useWC } from './uiKit';
|
||||
|
||||
// Test-first nudge before scheduling: a test run grants the tool access the
|
||||
// workflow needs, so unattended runs don't stall reaching for them.
|
||||
const SaveGuard: React.FC<{
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
onSaveAnyway: () => void;
|
||||
onRunTest: () => void;
|
||||
}> = ({ title, onClose, onSaveAnyway, onRunTest }) => {
|
||||
const WC = useWC();
|
||||
return (
|
||||
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: `rgba(${WC.inkRGB},0.34)`, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 28 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: 430, maxWidth: '100%', background: WC.paper, borderRadius: WC.radius.lg, boxShadow: WC.shadow.lg, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '24px 24px 18px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||
<div style={{ width: 30, height: 30, borderRadius: 9, background: 'rgba(185,138,46,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none' }}>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke={WC.warn} strokeWidth="2"><path d="M12 3l9 16H3z" /><path d="M12 10v4" /><path d="M12 17h.01" /></svg>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontFamily: "'Newsreader',serif", fontSize: 20, fontWeight: 500, color: WC.ink }}>Test run recommended</h3>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.6, color: WC.ink4 }}>
|
||||
You haven’t tested “{title}” yet. A quick test run confirms the steps work and grants the tool access it needs before it goes on a schedule.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ padding: '0 24px 22px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button onClick={onSaveAnyway} style={{ background: 'transparent', border: `1px solid rgba(${WC.inkRGB},0.16)`, borderRadius: 9, padding: '9px 16px', fontSize: 13, fontWeight: 600, color: WC.ink3, cursor: 'pointer' }}>Save anyway</button>
|
||||
<button onClick={onRunTest} style={{ display: 'flex', alignItems: 'center', gap: 7, background: WC.accent, color: '#fff', border: 'none', borderRadius: 9, padding: '9px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
|
||||
<div style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: '8px solid #fff', flex: 'none' }} />
|
||||
<span>Run test now</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SaveGuard;
|
||||
@@ -0,0 +1,222 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { Workflow, ScheduleConfig } from '@/shared/state/workflowsSlice';
|
||||
import { describeSchedule, needsScheduleTestWarning } from '@/app/pages/Workflows/scheduleUtils';
|
||||
import { useWC, FONT_SERIF, track, knob } from './uiKit';
|
||||
import {
|
||||
freqOf, patchForFreq, intervalMinutes, timeInputValue, parseTimeInput, ordinal, nextRunText, type Freq,
|
||||
} from './model';
|
||||
import { useWorkflowPatch } from './useWorkflowPatch';
|
||||
import RepeatField from './RepeatField';
|
||||
|
||||
const FREQS: Array<[Freq, string]> = [['daily', 'Daily'], ['weekly', 'Weekly'], ['monthly', 'Monthly'], ['interval', 'Interval']];
|
||||
const DAY_LABELS: Array<[string, number]> = [['S', 0], ['M', 1], ['T', 2], ['W', 3], ['T', 4], ['F', 5], ['S', 6]];
|
||||
|
||||
const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
|
||||
const WC = useWC();
|
||||
const patch = useWorkflowPatch();
|
||||
const sched = workflow.schedule;
|
||||
const freq = freqOf(sched);
|
||||
const enabled = sched.enabled;
|
||||
|
||||
const patchSched = (p: Partial<ScheduleConfig>) => patch(workflow, { schedule: { ...sched, ...p } });
|
||||
|
||||
// The "Run at" field is an uncontrolled native time input so React doesn't
|
||||
// reset the segment's pending-digit state between keystrokes (a controlled
|
||||
// value made typing 4 then 5 land 05 instead of 45). To still reflect edits
|
||||
// from elsewhere (e.g. the agent reschedules), push the store time in
|
||||
// imperatively, and only when it actually differs from what's shown.
|
||||
const timeRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const el = timeRef.current;
|
||||
if (!el) return;
|
||||
const want = timeInputValue(sched);
|
||||
if (el.value !== want) el.value = want;
|
||||
}, [sched.hour, sched.minute]);
|
||||
|
||||
// Turning a weekly schedule on with no days picked is "unconfigured", so the
|
||||
// backend silently forces it back off and the switch looks dead. Seed today's
|
||||
// weekday so the default Weekly 9am toggles on (and stays on) in one click.
|
||||
const toggleEnabled = () => {
|
||||
if (!enabled && sched.repeat_unit === 'week' && sched.on_days.length === 0) {
|
||||
patchSched({ enabled: true, on_days: [new Date().getDay()] });
|
||||
} else {
|
||||
patchSched({ enabled: !enabled });
|
||||
}
|
||||
};
|
||||
|
||||
// Local draft so the interval field can be cleared / mid-typed below the
|
||||
// floor without snapping; we warn instead and commit a valid value.
|
||||
const [intervalDraft, setIntervalDraft] = useState<string | null>(null);
|
||||
|
||||
const freqBtn = (active: boolean): CSSProperties => ({
|
||||
flex: 1, padding: '6px 2px', borderRadius: 7, border: 'none', cursor: 'pointer', fontSize: 11.5, fontWeight: 600,
|
||||
background: active ? WC.paper : 'transparent', color: active ? WC.ink : WC.muted,
|
||||
boxShadow: active ? WC.shadow.sm : 'none',
|
||||
});
|
||||
const pillBtn = (active: boolean): CSSProperties => ({
|
||||
flex: 1, height: 30, borderRadius: 7, border: `1px solid ${active ? WC.accent : `rgba(${WC.inkRGB},0.12)`}`,
|
||||
cursor: 'pointer', fontSize: 11.5, fontWeight: 600, background: active ? WC.accent : WC.raised, color: active ? '#fff' : WC.muted,
|
||||
});
|
||||
|
||||
const toggleDay = (d: number) => {
|
||||
const on = sched.on_days.includes(d);
|
||||
const next = on ? sched.on_days.filter((x) => x !== d) : [...sched.on_days, d];
|
||||
patchSched({ on_days: next.sort((a, b) => a - b) });
|
||||
};
|
||||
|
||||
const intervalMins = intervalMinutes(sched);
|
||||
const intervalUnit: 'min' | 'hour' = sched.repeat_unit === 'hour' ? 'hour' : 'min';
|
||||
const intervalValue = intervalUnit === 'hour' ? Math.max(1, Math.round(intervalMins / 60)) : intervalMins;
|
||||
const minInterval = intervalUnit === 'hour' ? 1 : 15;
|
||||
const shownInterval = intervalDraft ?? String(intervalValue);
|
||||
const intervalWarn = intervalDraft != null && /^\d+$/.test(intervalDraft) && parseInt(intervalDraft, 10) < minInterval;
|
||||
const dom = sched.day_of_month ?? 1;
|
||||
const lastDay = !!sched.last_day_of_month;
|
||||
const stepBtn: CSSProperties = { width: 26, height: 26, background: WC.raised, border: `1px solid rgba(${WC.inkRGB},0.12)`, borderRadius: 7, color: WC.ink3, fontSize: 15, cursor: lastDay ? 'default' : 'pointer', opacity: lastDay ? 0.4 : 1 };
|
||||
|
||||
const maxRuns = sched.max_runs;
|
||||
// Picking a finite limit resets the lifetime counter so "run 3 times" always means 3 from now.
|
||||
const setMaxRuns = (n: number | null) => patchSched(n == null ? { max_runs: null } : { max_runs: n, runs_count: 0 });
|
||||
|
||||
// A scheduled workflow with no steps fires but does nothing, so flag it.
|
||||
// Mirror the test-warning's draft-or-live read so the banner tracks edits.
|
||||
const hasNoSteps = !(workflow.draft_steps ?? workflow.steps ?? []).some((s) => s.text && s.text.trim());
|
||||
|
||||
return (
|
||||
<div style={{ background: WC.paper, border: `1px solid rgba(${WC.inkRGB},0.08)`, borderRadius: WC.radius.lg, padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 13 }}>
|
||||
<span style={{ fontFamily: FONT_SERIF, fontSize: 16, fontWeight: 500, color: WC.ink }}>Schedule</span>
|
||||
<div onClick={toggleEnabled} style={{ display: 'flex', alignItems: 'center', gap: 7, cursor: 'pointer' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: enabled ? WC.accent : WC.muted }}>{enabled ? 'On' : 'Off'}</span>
|
||||
<div style={track(enabled, WC)}><div style={knob(enabled)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enabled && hasNoSteps && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, marginBottom: 12, padding: '9px 11px', background: `${WC.warn}14`, border: `1px solid ${WC.warn}40`, borderRadius: 8 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={WC.warn} strokeWidth="1.9" style={{ flex: 'none', marginTop: 1 }}><path d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /></svg>
|
||||
<span style={{ fontSize: 12, color: WC.ink3, lineHeight: 1.45 }}>This workflow has no steps, so a scheduled run won't do anything. Add a step first.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabled && !hasNoSteps && needsScheduleTestWarning(workflow) && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, marginBottom: 12, padding: '9px 11px', background: `${WC.warn}14`, border: `1px solid ${WC.warn}40`, borderRadius: 8 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={WC.warn} strokeWidth="1.9" style={{ flex: 'none', marginTop: 1 }}><path d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /></svg>
|
||||
<span style={{ fontSize: 12, color: WC.ink3, lineHeight: 1.45 }}>Not test-run yet. A scheduled run can't pause for permission prompts, so if this needs tool access it may fail silently. Hit <b>Run</b> once to grant access.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', background: WC.inset, border: `1px solid ${WC.line}`, borderRadius: 9, padding: 3, gap: 2, marginBottom: 12 }}>
|
||||
{FREQS.map(([k, label]) => (
|
||||
<button key={k} onClick={() => patchSched(patchForFreq(sched, k))} style={freqBtn(freq === k)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{freq === 'weekly' && (
|
||||
<div style={{ display: 'flex', gap: 5, marginBottom: 12 }}>
|
||||
{DAY_LABELS.map(([label, d], i) => (
|
||||
<button key={i} onClick={() => toggleDay(d)} style={pillBtn(sched.on_days.includes(d))}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{freq === 'monthly' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: 10 }}>
|
||||
<span style={{ fontSize: 13, color: WC.ink3 }}>Day of month</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button onClick={() => { if (!lastDay) patchSched({ day_of_month: dom <= 1 ? 31 : dom - 1 }); }} style={stepBtn}>−</button>
|
||||
<span style={{ minWidth: 62, textAlign: 'center', fontFamily: "'JetBrains Mono',monospace", fontWeight: 500, fontSize: 13, color: WC.ink }}>{lastDay ? 'Last day' : ordinal(dom)}</span>
|
||||
<button onClick={() => { if (!lastDay) patchSched({ day_of_month: dom >= 31 ? 1 : dom + 1 }); }} style={stepBtn}>+</button>
|
||||
<button onClick={() => patchSched({ last_day_of_month: !lastDay })} style={{ height: 26, padding: '0 10px', borderRadius: 7, border: `1px solid ${lastDay ? WC.accent : `rgba(${WC.inkRGB},0.12)`}`, cursor: 'pointer', fontSize: 11.5, fontWeight: 600, background: lastDay ? WC.accent : WC.raised, color: lastDay ? '#fff' : WC.muted, flex: 'none' }}>Last</button>
|
||||
</div>
|
||||
</div>
|
||||
{!lastDay && dom >= 29 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 12 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={WC.warn} strokeWidth="1.9" style={{ flex: 'none' }}><path d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /></svg>
|
||||
<span style={{ fontSize: 11.5, color: WC.muted }}>Short months fire on their last day. Pick "Last" to always hit month-end.</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{freq !== 'interval' ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, color: WC.ink3 }}>Run at</span>
|
||||
<input
|
||||
type="time"
|
||||
ref={timeRef}
|
||||
defaultValue={timeInputValue(sched)}
|
||||
onChange={(e) => { const t = parseTimeInput(e.target.value); if (t) patchSched(t); }}
|
||||
style={{ width: 134, boxSizing: 'border-box', height: 32, background: WC.raised, border: `1px solid rgba(${WC.inkRGB},0.12)`, borderRadius: 8, padding: '0 9px', fontSize: 13, fontFamily: "'JetBrains Mono',monospace", color: WC.ink }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, color: WC.ink3 }}>Run every</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
type="text" inputMode="numeric" value={shownInterval}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.replace(/[^0-9]/g, '');
|
||||
setIntervalDraft(raw);
|
||||
const v = parseInt(raw, 10);
|
||||
if (raw !== '' && v >= minInterval) patchSched({ repeat_every: v });
|
||||
}}
|
||||
onBlur={() => {
|
||||
const v = parseInt(intervalDraft ?? '', 10);
|
||||
if (intervalDraft != null && (intervalDraft === '' || Number.isNaN(v) || v < minInterval)) {
|
||||
patchSched({ repeat_every: minInterval });
|
||||
}
|
||||
setIntervalDraft(null);
|
||||
}}
|
||||
style={{ width: 56, background: WC.raised, border: `1px solid ${intervalWarn ? WC.warn : `rgba(${WC.inkRGB},0.12)`}`, borderRadius: 8, padding: '6px 9px', fontSize: 13, fontFamily: "'JetBrains Mono',monospace", color: WC.ink, textAlign: 'right' }}
|
||||
/>
|
||||
<select
|
||||
value={intervalUnit}
|
||||
onChange={(e) => {
|
||||
setIntervalDraft(null);
|
||||
if (e.target.value === 'hour') patchSched({ repeat_unit: 'hour', repeat_every: Math.max(1, Math.round(intervalMins / 60)) });
|
||||
else patchSched({ repeat_unit: 'minute', repeat_every: Math.max(15, intervalMins) });
|
||||
}}
|
||||
style={{ background: WC.raised, border: `1px solid rgba(${WC.inkRGB},0.12)`, borderRadius: 8, padding: '6px 8px', fontSize: 13, color: WC.ink, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="min">minutes</option>
|
||||
<option value="hour">hours</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{freq === 'interval' && intervalWarn && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 7, justifyContent: 'flex-end' }}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={WC.warn} strokeWidth="2" style={{ flex: 'none' }}><path d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /></svg>
|
||||
<span style={{ fontSize: 11.5, color: WC.warn }}>Minimum is {minInterval} {intervalUnit === 'hour' ? 'hour' : 'minutes'}.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, color: WC.ink3 }}>Repeat</span>
|
||||
<RepeatField value={maxRuns} onChange={setMaxRuns} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 13, paddingTop: 13, borderTop: `1px solid ${WC.line}`, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={WC.muted} strokeWidth="1.8" style={{ flex: 'none' }}><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>
|
||||
<span style={{ fontSize: 12.5, color: WC.ink4 }}>{describeSchedule(sched)}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 7, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 14, display: 'flex', justifyContent: 'center', flex: 'none' }}><div style={{ width: 6, height: 6, borderRadius: '50%', background: WC.accent }} /></div>
|
||||
<span style={{ fontSize: 12.5, color: WC.ink4 }}>Next run {nextRunText(workflow, workflow.next_run_at ? new Date(workflow.next_run_at) : null)}</span>
|
||||
</div>
|
||||
{maxRuns != null && (
|
||||
<div style={{ marginTop: 7, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 14, display: 'flex', justifyContent: 'center', flex: 'none' }}><div style={{ width: 6, height: 6, borderRadius: '50%', background: WC.muted }} /></div>
|
||||
<span style={{ fontSize: 12.5, color: WC.ink4 }}>{sched.runs_count} of {maxRuns} run{maxRuns === 1 ? '' : 's'} done</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleCard;
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { commitDraft } from '@/shared/state/workflowsSlice';
|
||||
import type { Workflow, WorkflowStep } from '@/shared/state/workflowsSlice';
|
||||
import { stepsSignature } from '@/app/pages/Workflows/scheduleUtils';
|
||||
import { useWC, FONT_SERIF, FONT_SANS, track, knob } from './uiKit';
|
||||
import { useWorkflowPatch } from './useWorkflowPatch';
|
||||
|
||||
interface LocalStep { id: string; label: string; text: string; open: boolean; enabled: boolean; }
|
||||
|
||||
function toLocal(steps: WorkflowStep[]): LocalStep[] {
|
||||
return steps.map((s) => ({ id: s.id, label: s.label || s.text.slice(0, 48), text: s.text, open: false, enabled: s.enabled !== false }));
|
||||
}
|
||||
function newStepId(): string {
|
||||
return `step-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
}
|
||||
|
||||
const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
|
||||
const WC = useWC();
|
||||
const dispatch = useAppDispatch();
|
||||
const patch = useWorkflowPatch();
|
||||
const [local, setLocal] = useState<LocalStep[]>(() => toLocal(workflow.steps));
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
// Agent-proposed step changes apply silently (no Apply/Discard popup): commit
|
||||
// any staged draft as soon as it lands so the steps just update live. Guarded
|
||||
// on real content, the edit session snapshots an empty draft on open and
|
||||
// committing that 400s.
|
||||
useEffect(() => {
|
||||
if (workflow.has_draft && (workflow.draft_steps || []).some((s) => s.text && s.text.trim())) {
|
||||
dispatch(commitDraft({ id: workflow.id, keep_session: true }));
|
||||
}
|
||||
}, [workflow.has_draft, workflow.draft_steps, workflow.id, dispatch]);
|
||||
|
||||
const sig = stepsSignature(workflow.steps);
|
||||
// Reseed when the server steps change underneath us (commit, agent edit,
|
||||
// another surface) but not on our own in-progress keystrokes.
|
||||
useEffect(() => {
|
||||
setLocal((prev) => {
|
||||
const openIds = new Set(prev.filter((s) => s.open).map((s) => s.id));
|
||||
return toLocal(workflow.steps).map((s) => ({ ...s, open: openIds.has(s.id) }));
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sig]);
|
||||
|
||||
// A manual add lands with an empty label that the backend names from the step
|
||||
// text. The label arrives without changing the steps signature (same text), so
|
||||
// fill it in here without a full reseed and without touching a label you're
|
||||
// mid-typing.
|
||||
useEffect(() => {
|
||||
setLocal((prev) => {
|
||||
const byId = new Map(workflow.steps.map((s) => [s.id, s]));
|
||||
let changed = false;
|
||||
const next = prev.map((s) => {
|
||||
const srv = byId.get(s.id);
|
||||
if (srv && !s.label.trim() && (srv.label || '').trim()) {
|
||||
changed = true;
|
||||
return { ...s, label: srv.label as string };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [workflow.steps]);
|
||||
|
||||
const commit = (next: LocalStep[]) => {
|
||||
patch(workflow, { steps: next.map((s) => ({ id: s.id, text: s.text, label: s.label, enabled: s.enabled })) });
|
||||
};
|
||||
|
||||
const update = (id: string, p: Partial<LocalStep>) => setLocal((prev) => prev.map((s) => (s.id === id ? { ...s, ...p } : s)));
|
||||
const toggleEnabled = (id: string) => {
|
||||
const next = local.map((s) => (s.id === id ? { ...s, enabled: !s.enabled } : s));
|
||||
setLocal(next);
|
||||
commit(next);
|
||||
};
|
||||
const onAdd = () => {
|
||||
const t = draft.trim();
|
||||
if (!t) return;
|
||||
// What you type is the step's prompt; the short label is generated from it
|
||||
// server-side (empty label tells the backend to name this step).
|
||||
const next = [...local, { id: newStepId(), label: '', text: t, open: false, enabled: true }];
|
||||
setLocal(next);
|
||||
setDraft('');
|
||||
commit(next);
|
||||
};
|
||||
const onDelete = (id: string) => {
|
||||
const next = local.filter((s) => s.id !== id);
|
||||
setLocal(next);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ background: WC.paper, border: `1px solid rgba(${WC.inkRGB},0.08)`, borderRadius: WC.radius.lg, padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 13 }}>
|
||||
<span style={{ fontFamily: FONT_SERIF, fontSize: 16, fontWeight: 500, color: WC.ink }}>Steps</span>
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, color: WC.muted2 }}>{local.length} step{local.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{local.map((s, i) => (
|
||||
<div key={s.id} style={{ border: `1px solid ${s.open ? `rgba(${WC.inkRGB},0.16)` : `rgba(${WC.inkRGB},0.10)`}`, borderRadius: WC.radius.md, background: s.open ? WC.raised : WC.paper, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '9px 9px 9px 11px', opacity: s.enabled ? 1 : 0.5 }}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, color: WC.faint, width: 13, flex: 'none' }}>{i + 1}</span>
|
||||
<input
|
||||
value={s.label}
|
||||
onChange={(e) => update(s.id, { label: e.target.value })}
|
||||
onBlur={() => commit(local)}
|
||||
placeholder="Step title"
|
||||
style={{ flex: 1, minWidth: 0, border: 'none', background: 'transparent', padding: 0, fontSize: 13, fontWeight: 600, color: WC.ink, textDecoration: s.enabled ? 'none' : 'line-through' }}
|
||||
/>
|
||||
<div onClick={() => toggleEnabled(s.id)} title={s.enabled ? 'Disable step' : 'Enable step'} style={{ ...track(s.enabled, WC), transform: 'scale(0.82)' }}><div style={knob(s.enabled)} /></div>
|
||||
<div onClick={() => update(s.id, { open: !s.open })} style={{ width: 22, height: 22, borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.muted, flex: 'none' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" style={{ transform: s.open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}><path d="M6 9l6 6 6-6" /></svg>
|
||||
</div>
|
||||
<div onClick={() => onDelete(s.id)} style={{ width: 22, height: 22, borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.faint, flex: 'none' }} aria-label="Delete step">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
{!s.open && s.text.trim() && s.text.trim() !== s.label.trim() && (
|
||||
<div style={{ padding: '0 11px 10px 34px', fontSize: 12, color: WC.muted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.text}</div>
|
||||
)}
|
||||
{s.open && (
|
||||
<div style={{ padding: '0 11px 12px 34px' }}>
|
||||
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 9.5, letterSpacing: '0.05em', textTransform: 'uppercase', color: WC.muted2, marginBottom: 6 }}>Prompt</div>
|
||||
<textarea
|
||||
value={s.text}
|
||||
onChange={(e) => update(s.id, { text: e.target.value })}
|
||||
onBlur={() => commit(local)}
|
||||
placeholder="What should this step do?"
|
||||
style={{ width: '100%', boxSizing: 'border-box', border: `1px solid rgba(${WC.inkRGB},0.12)`, borderRadius: 8, background: WC.paper, padding: '9px 11px', fontSize: 12.5, lineHeight: 1.5, color: WC.ink2, resize: 'vertical', minHeight: 76, fontFamily: FONT_SANS }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 7, marginTop: 11 }}>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onAdd(); } }}
|
||||
placeholder="Add a step…"
|
||||
style={{ flex: 1, background: WC.raised, border: `1px solid rgba(${WC.inkRGB},0.12)`, borderRadius: 8, padding: '8px 11px', fontSize: 13, color: WC.ink }}
|
||||
/>
|
||||
<button onClick={onAdd} style={{ background: WC.ink, color: WC.paper, border: 'none', borderRadius: 8, width: 34, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flex: 'none' }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"><path d="M12 5v14M5 12h14" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepsCard;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { fetchDeletedWorkflows, restoreWorkflow, purgeWorkflow } from '@/shared/state/workflowsSlice';
|
||||
import { colorForWorkflow, useWC } from './uiKit';
|
||||
import { whenText } from './model';
|
||||
|
||||
const TrashView: React.FC = () => {
|
||||
const WC = useWC();
|
||||
const dispatch = useAppDispatch();
|
||||
const dashboardId = useAppSelector((s) => s.tempState.lastDashboardId) || undefined;
|
||||
const deleted = useAppSelector((s) => s.workflows.deleted);
|
||||
const loading = useAppSelector((s) => s.workflows.deletedLoading);
|
||||
|
||||
useEffect(() => { dispatch(fetchDeletedWorkflows(dashboardId)); }, [dashboardId, dispatch]);
|
||||
|
||||
const now = new Date();
|
||||
const onPurge = (id: string, title: string) => {
|
||||
if (!window.confirm(`Permanently delete "${title}"? This can't be undone.`)) return;
|
||||
dispatch(purgeWorkflow(id));
|
||||
};
|
||||
// when clicking a run and the run card pops up, make the card pop up slightly more to the right.
|
||||
return (
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: WC.paper }}>
|
||||
<div style={{ flex: 'none', padding: '22px 30px 14px', borderBottom: `1px solid ${WC.line}` }}>
|
||||
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, letterSpacing: '0.06em', textTransform: 'uppercase', color: WC.muted2, marginBottom: 5 }}>Deleted workflows</div>
|
||||
<h1 style={{ margin: 0, fontFamily: "'Newsreader',serif", fontSize: 29, fontWeight: 500, color: WC.ink, letterSpacing: '-0.015em' }}>Trash</h1>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, padding: '18px 30px 32px' }}>
|
||||
{deleted.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
|
||||
{deleted.map((w) => (
|
||||
<div key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 14, background: WC.raised, border: `1px solid rgba(${WC.inkRGB},0.08)`, borderRadius: WC.radius.md, padding: '13px 16px' }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', flex: 'none', background: colorForWorkflow(w) }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: WC.ink, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{w.title || 'Untitled workflow'}</div>
|
||||
<div style={{ fontSize: 12, color: WC.muted, marginTop: 2 }}>{w.deleted_at ? `Deleted ${whenText(new Date(w.deleted_at), now)}` : 'Deleted'} · {w.steps.length} step{w.steps.length === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
<button onClick={() => dispatch(restoreWorkflow(w.id))} style={{ background: WC.raised, border: `1px solid rgba(${WC.inkRGB},0.14)`, borderRadius: 8, padding: '7px 14px', fontSize: 12.5, fontWeight: 600, color: WC.ink, cursor: 'pointer', flex: 'none' }}>Restore</button>
|
||||
<button onClick={() => onPurge(w.id, w.title || 'this workflow')} style={{ background: WC.dangerBg, border: 'none', borderRadius: 8, padding: '7px 14px', fontSize: 12.5, fontWeight: 600, color: WC.danger, cursor: 'pointer', flex: 'none' }}>Delete forever</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
!loading && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '84px 20px', textAlign: 'center' }}>
|
||||
<div style={{ width: 46, height: 46, borderRadius: 12, background: WC.inset, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 14 }}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={WC.faint} strokeWidth="1.7"><path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13" /></svg>
|
||||
</div>
|
||||
<div style={{ fontFamily: "'Newsreader',serif", fontSize: 18, color: WC.ink, marginBottom: 4 }}>Trash is empty</div>
|
||||
<div style={{ fontSize: 13, color: WC.muted, maxWidth: 300, lineHeight: 1.5 }}>Deleted workflows appear here. Restore them or remove them permanently.</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrashView;
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { Typewriter } from '@/app/components/feedback/Animated';
|
||||
|
||||
interface WorkflowTitleProps {
|
||||
// Raw title; the 'Untitled workflow' fallback is applied here so every surface
|
||||
// agrees on the empty-name text.
|
||||
value: string | null | undefined;
|
||||
// Animate AI-driven renames only. Pass `workflow.auto_named !== false`: a user
|
||||
// rename flips auto_named false and the new title should just snap (they typed
|
||||
// it), while the build agent's first-step rename types in like the chat card.
|
||||
animate: boolean;
|
||||
children: (shown: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
// One home for the workflow-title typewriter so every place a workflow name
|
||||
// renders animates the same way AgentCard does when its name regenerates.
|
||||
export const WorkflowTitle: React.FC<WorkflowTitleProps> = ({ value, animate, children }) => (
|
||||
<Typewriter value={value || 'Untitled workflow'} enabled={animate}>
|
||||
{children}
|
||||
</Typewriter>
|
||||
);
|
||||
|
||||
export default WorkflowTitle;
|
||||
@@ -0,0 +1,281 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { closeWorkflowsApp, setWorkflowsHubPosition, setWorkflowsHubSize } from '@/shared/state/dashboardLayoutSlice';
|
||||
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useWC, FONT_SERIF } from './uiKit';
|
||||
import WorkflowsAppContent from './WorkflowsAppContent';
|
||||
|
||||
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
||||
type CardType = 'workflows-hub';
|
||||
|
||||
const EDGE = 6;
|
||||
const CORNER = 14;
|
||||
const MIN_W = 900;
|
||||
const MIN_H = 520;
|
||||
const DRAG_THRESHOLD = 3;
|
||||
|
||||
const CURSOR_MAP: Record<ResizeDir, string> = {
|
||||
n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize',
|
||||
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
|
||||
};
|
||||
const HANDLE_DEFS: { dir: ResizeDir; css: React.CSSProperties }[] = [
|
||||
{ dir: 'n', css: { top: -EDGE / 2, left: CORNER, right: CORNER, height: EDGE } },
|
||||
{ dir: 's', css: { bottom: -EDGE / 2, left: CORNER, right: CORNER, height: EDGE } },
|
||||
{ dir: 'w', css: { left: -EDGE / 2, top: CORNER, bottom: CORNER, width: EDGE } },
|
||||
{ dir: 'e', css: { right: -EDGE / 2, top: CORNER, bottom: CORNER, width: EDGE } },
|
||||
{ dir: 'nw', css: { top: -EDGE / 2, left: -EDGE / 2, width: CORNER, height: CORNER } },
|
||||
{ dir: 'ne', css: { top: -EDGE / 2, right: -EDGE / 2, width: CORNER, height: CORNER } },
|
||||
{ dir: 'sw', css: { bottom: -EDGE / 2, left: -EDGE / 2, width: CORNER, height: CORNER } },
|
||||
{ dir: 'se', css: { bottom: -EDGE / 2, right: -EDGE / 2, width: CORNER, height: CORNER } },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
cardX: number;
|
||||
cardY: number;
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
cardZOrder?: number;
|
||||
zoom?: number;
|
||||
panX?: number;
|
||||
panY?: number;
|
||||
isSelected?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
onCardSelect?: (id: string, type: CardType, shiftKey: boolean) => void;
|
||||
onDragStart?: (id: string, type: CardType) => void;
|
||||
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
|
||||
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
|
||||
onBringToFront?: (id: string, type: CardType) => void;
|
||||
}
|
||||
|
||||
const WorkflowsAppCard: React.FC<Props> = ({
|
||||
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
|
||||
zoom = 1, panX = 0, panY = 0,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta = null,
|
||||
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
|
||||
}) => {
|
||||
const WC = useWC();
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const panRef = useRef({ panX, panY });
|
||||
panRef.current = { panX, panY };
|
||||
const zoomRef = useRef(zoom);
|
||||
zoomRef.current = zoom;
|
||||
|
||||
// ---- Drag (title bar is the handle) ----
|
||||
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const didDrag = useRef(false);
|
||||
const justDraggedRef = useRef(false);
|
||||
|
||||
// Keep fonts/keyframes available while the card is mounted.
|
||||
useEffect(() => { ensureAssets(); }, []);
|
||||
|
||||
const onHeaderPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-no-drag], button, [role="button"], input, textarea, select')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
|
||||
didDrag.current = false;
|
||||
setIsDragging(true);
|
||||
onDragStart?.('workflows-hub', 'workflows-hub');
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [cardX, cardY, onDragStart]);
|
||||
|
||||
const onHeaderPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const rawDx = e.clientX - dragState.current.startX;
|
||||
const rawDy = e.clientY - dragState.current.startY;
|
||||
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
|
||||
didDrag.current = true;
|
||||
const z = zoomRef.current;
|
||||
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
|
||||
const dx = rawDx / z - panDx;
|
||||
const dy = rawDy / z - panDy;
|
||||
setLocalDragPos({ x: dragState.current.origX + dx, y: dragState.current.origY + dy });
|
||||
onDragMove?.(dx, dy, e.clientX, e.clientY);
|
||||
}, [onDragMove]);
|
||||
|
||||
const onHeaderPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const z = zoomRef.current;
|
||||
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
|
||||
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
|
||||
const dx = (e.clientX - dragState.current.startX) / z - panDx;
|
||||
const dy = (e.clientY - dragState.current.startY) / z - panDy;
|
||||
if (didDrag.current) {
|
||||
justDraggedRef.current = true;
|
||||
setTimeout(() => { justDraggedRef.current = false; }, 0);
|
||||
let finalX = dragState.current.origX + dx;
|
||||
let finalY = dragState.current.origY + dy;
|
||||
if (!e.shiftKey) { finalX = Math.round(finalX / 24) * 24; finalY = Math.round(finalY / 24) * 24; }
|
||||
dispatch(setWorkflowsHubPosition({ x: finalX, y: finalY }));
|
||||
}
|
||||
onDragEnd?.(dx, dy, didDrag.current);
|
||||
dragState.current = null;
|
||||
didDrag.current = false;
|
||||
setLocalDragPos(null);
|
||||
setIsDragging(false);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [dispatch, onDragEnd]);
|
||||
|
||||
// ---- Resize ----
|
||||
const resizeRef = useRef<{ dir: ResizeDir; sx0: number; sy0: number; ox: number; oy: number; ow: number; oh: number } | null>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
|
||||
const onResizeDown = useCallback((dir: ResizeDir) => (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resizeRef.current = { dir, sx0: e.clientX, sy0: e.clientY, ox: cardX, oy: cardY, ow: cardWidth, oh: cardHeight };
|
||||
setIsResizing(true);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [cardX, cardY, cardWidth, cardHeight]);
|
||||
|
||||
const compute = useCallback((e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return null;
|
||||
const { dir, sx0, sy0, ox, oy, ow, oh } = resizeRef.current;
|
||||
const dx = (e.clientX - sx0) / zoomRef.current;
|
||||
const dy = (e.clientY - sy0) / zoomRef.current;
|
||||
let nx = ox, ny = oy, nw = ow, nh = oh;
|
||||
if (dir.includes('e')) nw = ow + dx;
|
||||
if (dir.includes('w')) { nw = ow - dx; nx = ox + dx; }
|
||||
if (dir.includes('s')) nh = oh + dy;
|
||||
if (dir.includes('n')) { nh = oh - dy; ny = oy + dy; }
|
||||
if (nw < MIN_W) { if (dir.includes('w')) nx = ox + ow - MIN_W; nw = MIN_W; }
|
||||
if (nh < MIN_H) { if (dir.includes('n')) ny = oy + oh - MIN_H; nh = MIN_H; }
|
||||
return { x: nx, y: ny, w: nw, h: nh };
|
||||
}, []);
|
||||
|
||||
const onResizeMove = useCallback((e: React.PointerEvent) => {
|
||||
const r = compute(e);
|
||||
if (r) setLocalResize(r);
|
||||
}, [compute]);
|
||||
|
||||
const onResizeUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const r = compute(e);
|
||||
if (r) {
|
||||
dispatch(setWorkflowsHubPosition({ x: r.x, y: r.y }));
|
||||
dispatch(setWorkflowsHubSize({ width: r.w, height: r.h }));
|
||||
}
|
||||
resizeRef.current = null;
|
||||
setLocalResize(null);
|
||||
setIsResizing(false);
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [compute, dispatch]);
|
||||
|
||||
const mdDx = (!isDragging && !isResizing && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
|
||||
const mdDy = (!isDragging && !isResizing && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
|
||||
const dx = (localResize?.x ?? localDragPos?.x ?? cardX) + mdDx;
|
||||
const dy = (localResize?.y ?? localDragPos?.y ?? cardY) + mdDy;
|
||||
const dw = localResize?.w ?? cardWidth;
|
||||
const dh = localResize?.h ?? cardHeight;
|
||||
|
||||
const border = isHighlighted ? `2px solid ${WC.accent}` : isSelected ? '2px solid #3b82f6' : `1px solid ${WC.border.subtle}`;
|
||||
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-select-type="workflows-hub-card"
|
||||
data-select-id="workflows-hub"
|
||||
data-select-meta={JSON.stringify({ name: 'Workflows' })}
|
||||
onPointerDownCapture={(e: React.PointerEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-no-drag]')) return;
|
||||
onBringToFront?.('workflows-hub', 'workflows-hub');
|
||||
}}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (justDraggedRef.current) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-no-drag]')) return;
|
||||
onCardSelect?.('workflows-hub', 'workflows-hub', e.shiftKey);
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
contain: 'layout style',
|
||||
willChange: 'transform',
|
||||
left: dx, top: dy, width: dw, height: dh,
|
||||
background: WC.page,
|
||||
border,
|
||||
borderRadius: WC.radius.lg,
|
||||
boxShadow: (isDragging || isResizing) ? WC.shadow.lg : WC.shadow.md,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: (isDragging || isResizing) ? 999999 : cardZOrder,
|
||||
transition: noTransition ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{/* TITLE BAR — drag handle */}
|
||||
<div
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
onPointerMove={onHeaderPointerMove}
|
||||
onPointerUp={onHeaderPointerUp}
|
||||
style={{ height: 42, flex: 'none', display: 'flex', alignItems: 'center', padding: '0 16px', borderBottom: `1px solid ${WC.line}`, background: WC.panel, gap: 14, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<EventRepeatIcon sx={{ fontSize: 18, color: WC.accent, display: 'block' }} />
|
||||
<span style={{ fontFamily: FONT_SERIF, fontSize: 14.5, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em', lineHeight: 1, transform: 'translateY(2.5px)' }}>Workflows</span>
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<IconButton
|
||||
aria-label="Close"
|
||||
data-no-drag
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); dispatch(closeWorkflowsApp()); }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error, bgcolor: `${c.status.error}14` } }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<WorkflowsAppContent />
|
||||
|
||||
{HANDLE_DEFS.map(({ dir, css }) => (
|
||||
<div
|
||||
key={dir}
|
||||
data-no-drag
|
||||
onPointerDown={onResizeDown(dir)}
|
||||
onPointerMove={onResizeMove}
|
||||
onPointerUp={onResizeUp}
|
||||
style={{ position: 'absolute', cursor: CURSOR_MAP[dir], zIndex: 25, ...css }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FONTS_HREF = 'https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap';
|
||||
|
||||
// Inject the design's webfonts + spinner keyframe once, lazily.
|
||||
function ensureAssets(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (document.getElementById('workflows-app-fonts')) return;
|
||||
const link = document.createElement('link');
|
||||
link.id = 'workflows-app-fonts';
|
||||
link.rel = 'stylesheet';
|
||||
link.href = FONTS_HREF;
|
||||
document.head.appendChild(link);
|
||||
const style = document.createElement('style');
|
||||
style.id = 'workflows-app-keyframes';
|
||||
style.textContent = [
|
||||
'@keyframes os-spin { to { transform: rotate(360deg); } }',
|
||||
'@keyframes os-flow { to { stroke-dashoffset: -18; } }',
|
||||
'@keyframes os-pulse { 0%,100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.55); opacity: 0.45; } }',
|
||||
'@keyframes os-slidein { from { opacity: 0; transform: translateX(-22px) scale(0.97); } to { opacity: 1; transform: none; } }',
|
||||
].join('\n');
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
export default WorkflowsAppCard;
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { clearWorkflowsAppTarget } from '@/shared/state/dashboardLayoutSlice';
|
||||
import {
|
||||
fetchWorkflows, fetchAllRuns, fetchPausedState, fetchActiveRuns, fetchDeletedWorkflows,
|
||||
} from '@/shared/state/workflowsSlice';
|
||||
import { fetchMissedRuns } from '@/shared/state/missedRunsSlice';
|
||||
import { FONT_SANS, useWC } from './uiKit';
|
||||
import type { AppMode, CalView, AppNav } from './types';
|
||||
import LeftRail from './LeftRail';
|
||||
import HomeView from './HomeView';
|
||||
import CalendarView from './CalendarView';
|
||||
import DetailView from './DetailView';
|
||||
import ComposeView from './ComposeView';
|
||||
import TrashView from './TrashView';
|
||||
|
||||
// The three-pane Workflows body, independent of how it's framed (canvas card).
|
||||
// Holds nav + data; the card chrome (title bar drag handle, resize) wraps it.
|
||||
const WorkflowsAppContent: React.FC = () => {
|
||||
const WC = useWC();
|
||||
const dispatch = useAppDispatch();
|
||||
const target = useAppSelector((s) => s.dashboardLayout.workflowsAppTarget);
|
||||
const dashboardId = useAppSelector((s) => s.tempState.lastDashboardId) || undefined;
|
||||
|
||||
const [mode, setMode] = useState<AppMode>('home');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [calView, setCalView] = useState<CalView>('month');
|
||||
const [refDate, setRefDate] = useState<Date>(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchWorkflows(dashboardId));
|
||||
dispatch(fetchAllRuns(200));
|
||||
dispatch(fetchPausedState());
|
||||
dispatch(fetchActiveRuns());
|
||||
dispatch(fetchMissedRuns());
|
||||
dispatch(fetchDeletedWorkflows(dashboardId));
|
||||
}, [dashboardId, dispatch]);
|
||||
|
||||
// A deep-link target (history/notifications/toasts) jumps to that workflow's
|
||||
// detail, then clears so a later manual Home nav isn't overridden.
|
||||
useEffect(() => {
|
||||
if (target) {
|
||||
setSelectedId(target);
|
||||
setMode('detail');
|
||||
dispatch(clearWorkflowsAppTarget());
|
||||
}
|
||||
}, [target, dispatch]);
|
||||
|
||||
const nav: AppNav = useMemo(() => ({
|
||||
mode, selectedId, calView, refDate,
|
||||
goHome: () => setMode('home'),
|
||||
goCalendar: () => setMode('calendar'),
|
||||
goNew: () => { setSelectedId(null); setMode('new'); },
|
||||
goTrash: () => { dispatch(fetchDeletedWorkflows(dashboardId)); setMode('trash'); },
|
||||
selectWorkflow: (id: string) => { setSelectedId(id); setMode('detail'); },
|
||||
setCalView: (v: CalView) => setCalView(v),
|
||||
setRefDate: (d: Date) => setRefDate(d),
|
||||
}), [mode, selectedId, calView, refDate, dashboardId, dispatch]);
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', minHeight: 0, fontFamily: FONT_SANS, color: WC.ink, background: WC.page }}>
|
||||
<LeftRail nav={nav} />
|
||||
{mode === 'home' && <HomeView nav={nav} />}
|
||||
{mode === 'calendar' && <CalendarView nav={nav} />}
|
||||
{mode === 'detail' && selectedId && <DetailView workflowId={selectedId} nav={nav} />}
|
||||
{mode === 'new' && <ComposeView nav={nav} />}
|
||||
{mode === 'trash' && <TrashView />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowsAppContent;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { API_BASE, getAuthToken } from '@/shared/config';
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
let tok = '';
|
||||
try { tok = getAuthToken(); } catch { tok = ''; }
|
||||
return { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) };
|
||||
}
|
||||
|
||||
const base = `${API_BASE}/workflows`;
|
||||
|
||||
// Sticky single edit-agent session for a workflow. The backend snapshots steps
|
||||
// into draft_steps when it first hands one out; reattaches on later calls.
|
||||
export async function ensureEditAgentSession(workflowId: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${base}/${encodeURIComponent(workflowId)}/edit-agent-session`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return (data?.session_id as string | undefined) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Send a chat question with a run's transcript riding along as hidden context
|
||||
// for that single turn, so the answer is grounded in the run without an extra
|
||||
// "I've reviewed it" round-trip. The user's bubble shows just their question.
|
||||
export async function askRun(
|
||||
workflowId: string,
|
||||
body: { runId: string; prompt: string; mode?: string; model?: string },
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${base}/${encodeURIComponent(workflowId)}/ask-run`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ run_id: body.runId, prompt: body.prompt, mode: body.mode, model: body.model }),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { Workflow, WorkflowRun, ScheduleConfig, ActiveRun } from '@/shared/state/workflowsSlice';
|
||||
import type { WorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { isScheduleActive } from '@/app/pages/Workflows/scheduleUtils';
|
||||
|
||||
// The design speaks in four cadence buckets; the backend speaks in repeat_unit.
|
||||
// These two functions are the only place the two vocabularies meet.
|
||||
export type Freq = 'daily' | 'weekly' | 'monthly' | 'interval';
|
||||
|
||||
export function freqOf(sched: ScheduleConfig): Freq {
|
||||
switch (sched.repeat_unit) {
|
||||
case 'minute':
|
||||
case 'hour':
|
||||
return 'interval';
|
||||
case 'day':
|
||||
return 'daily';
|
||||
case 'month':
|
||||
return 'monthly';
|
||||
default:
|
||||
return 'weekly';
|
||||
}
|
||||
}
|
||||
|
||||
// Build the schedule patch for a cadence-button press, preserving the user's
|
||||
// existing time/days where the new cadence still uses them.
|
||||
export function patchForFreq(sched: ScheduleConfig, freq: Freq): Partial<ScheduleConfig> {
|
||||
switch (freq) {
|
||||
case 'daily':
|
||||
return { repeat_unit: 'day', repeat_every: 1 };
|
||||
case 'weekly':
|
||||
return { repeat_unit: 'week', repeat_every: 1, on_days: sched.on_days.length ? sched.on_days : [1] };
|
||||
case 'monthly':
|
||||
return { repeat_unit: 'month', repeat_every: 1, day_of_month: sched.day_of_month ?? 1 };
|
||||
case 'interval':
|
||||
return { repeat_unit: 'minute', repeat_every: Math.max(15, sched.repeat_every || 30) };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function intervalMinutes(sched: ScheduleConfig): number {
|
||||
if (sched.repeat_unit === 'hour') return Math.max(1, sched.repeat_every) * 60;
|
||||
if (sched.repeat_unit === 'minute') return Math.max(15, sched.repeat_every);
|
||||
return 30;
|
||||
}
|
||||
|
||||
export function formatInterval(mins: number): string {
|
||||
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'}`;
|
||||
if (mins % 60 === 0) { const h = mins / 60; return `${h} hour${h === 1 ? '' : 's'}`; }
|
||||
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
||||
}
|
||||
|
||||
export function isRunning(wf: Workflow, active: ActiveRun[]): boolean {
|
||||
return wf.last_run_status === 'running' || active.some((a) => a.workflow_id === wf.id);
|
||||
}
|
||||
|
||||
const TIME_OPTS: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' };
|
||||
|
||||
export function clockOf(date: Date): string {
|
||||
return date.toLocaleTimeString([], TIME_OPTS).toLowerCase().replace(' ', '');
|
||||
}
|
||||
|
||||
// "today", "tomorrow", or "Mon Jun 23" for next-run and coming-up labels.
|
||||
export function relativeDayLabel(date: Date, now = new Date()): string {
|
||||
const a = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const b = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const days = Math.round((a.getTime() - b.getTime()) / 86400000);
|
||||
if (days === 0) return 'today';
|
||||
if (days === 1) return 'tomorrow';
|
||||
if (days === -1) return 'yesterday';
|
||||
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// `next` is the backend-computed next_run_at (authoritative), not a JS reimpl.
|
||||
export function nextRunText(wf: Workflow, next: Date | null): string {
|
||||
if (!isScheduleActive(wf.schedule)) return 'Paused';
|
||||
if (!next) return 'None scheduled';
|
||||
return `${relativeDayLabel(next)} at ${clockOf(next)}`;
|
||||
}
|
||||
|
||||
// time <input type=time> value (24h "HH:MM") <-> hour/minute
|
||||
export function timeInputValue(sched: ScheduleConfig): string {
|
||||
return `${String(sched.hour).padStart(2, '0')}:${String(sched.minute).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function parseTimeInput(value: string): { hour: number; minute: number } | null {
|
||||
const m = value.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!m) return null;
|
||||
const hour = Math.max(0, Math.min(23, Number(m[1])));
|
||||
const minute = Math.max(0, Math.min(59, Number(m[2])));
|
||||
return { hour, minute };
|
||||
}
|
||||
|
||||
export function ordinal(n: number): string {
|
||||
const s = ['th', 'st', 'nd', 'rd'];
|
||||
const v = n % 100;
|
||||
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||
}
|
||||
|
||||
export interface RunRow {
|
||||
id: string;
|
||||
status: WorkflowRun['status'];
|
||||
summary: string;
|
||||
when: Date | null;
|
||||
durationText: string;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export function runSummary(run: WorkflowRun, fallbackTitle: string): string {
|
||||
if (run.error) return run.error;
|
||||
if (run.last_tool_label) return run.last_tool_label;
|
||||
if (run.status === 'skipped') return 'Skipped';
|
||||
if (run.status === 'running') return 'Running…';
|
||||
return fallbackTitle;
|
||||
}
|
||||
|
||||
export function runDuration(run: WorkflowRun): string {
|
||||
if (!run.finished_at || !run.started_at) return '';
|
||||
const ms = new Date(run.finished_at).getTime() - new Date(run.started_at).getTime();
|
||||
if (Number.isNaN(ms) || ms < 0) return '';
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
export function runContextChip(workflow: Workflow, run: WorkflowRun): WorkflowsRunContext {
|
||||
const total = workflow.steps.length;
|
||||
const aidx = run.active_step_idx ?? 0;
|
||||
const dur = runDuration(run);
|
||||
const statusWord = run.status === 'success' ? 'completed'
|
||||
: run.status === 'ran_late' ? 'completed late'
|
||||
: run.status === 'failure' ? 'failed'
|
||||
: run.status === 'running' ? 'running'
|
||||
: run.status === 'skipped' ? 'skipped' : run.status;
|
||||
const stepsPart = run.status === 'failure' ? `failed at step ${Math.min(aidx + 1, total)}`
|
||||
: (run.status === 'success' || run.status === 'ran_late') ? `${total}/${total} steps`
|
||||
: total > 0 ? `${Math.min(aidx, total)}/${total} steps` : '';
|
||||
const title = run.triggered_by === 'manual' ? 'Manual run'
|
||||
: run.triggered_by === 'retry' ? 'Re-run' : 'Scheduled run';
|
||||
const color = run.status === 'failure' ? '#C2483A'
|
||||
: (run.status === 'success' || run.status === 'ran_late') ? '#3F8E5B' : '#C25A36';
|
||||
return {
|
||||
workflowId: workflow.id,
|
||||
runId: run.id,
|
||||
title,
|
||||
metaLabel: [statusWord, dur, stepsPart].filter(Boolean).join(' · '),
|
||||
color,
|
||||
};
|
||||
}
|
||||
|
||||
export function toRunRow(run: WorkflowRun, title: string): RunRow {
|
||||
return {
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
summary: runSummary(run, title),
|
||||
when: run.started_at ? new Date(run.started_at) : (run.scheduled_for ? new Date(run.scheduled_for) : null),
|
||||
durationText: runDuration(run),
|
||||
cost: run.cost_usd,
|
||||
};
|
||||
}
|
||||
|
||||
export function whenText(date: Date | null, now = new Date()): string {
|
||||
if (!date) return '';
|
||||
const rel = relativeDayLabel(date, now);
|
||||
const cap = rel.charAt(0).toUpperCase() + rel.slice(1);
|
||||
return `${cap}, ${clockOf(date)}`;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type AppMode = 'home' | 'calendar' | 'detail' | 'new' | 'trash';
|
||||
export type CalView = 'week' | 'month';
|
||||
|
||||
// Navigation + ephemeral UI state for the Workflows app window. Data lives in
|
||||
// Redux; this is only "where am I looking right now".
|
||||
export interface AppNav {
|
||||
mode: AppMode;
|
||||
selectedId: string | null;
|
||||
calView: CalView;
|
||||
refDate: Date;
|
||||
goHome: () => void;
|
||||
goCalendar: () => void;
|
||||
goNew: () => void;
|
||||
goTrash: () => void;
|
||||
selectWorkflow: (id: string) => void;
|
||||
setCalView: (v: CalView) => void;
|
||||
setRefDate: (d: Date) => void;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
|
||||
// The Workflows app renders in its own warm-paper visual language (from the
|
||||
// Claude design), deliberately separate from the MUI theme so the window reads
|
||||
// as a focused app. Two palettes (light/dark) keyed to the app theme; grab one
|
||||
// per component with useWC(). inkRGB is the ink color as a bare "r,g,b" so the
|
||||
// many rgba(ink, opacity) borders/hovers can flip with the theme too.
|
||||
export interface WCPalette {
|
||||
accent: string;
|
||||
paper: string;
|
||||
page: string;
|
||||
panel: string;
|
||||
rail: string;
|
||||
inset: string;
|
||||
raised: string;
|
||||
ink: string;
|
||||
ink2: string;
|
||||
ink3: string;
|
||||
ink4: string;
|
||||
muted: string;
|
||||
muted2: string;
|
||||
faint: string;
|
||||
inkRGB: string;
|
||||
line: string;
|
||||
line2: string;
|
||||
hover: string;
|
||||
selBg: string;
|
||||
success: string;
|
||||
successBg: string;
|
||||
danger: string;
|
||||
dangerBg: string;
|
||||
warn: string;
|
||||
warnBg: string;
|
||||
trackOff: string;
|
||||
// Structural primitives shared with the rest of OpenSwarm (sourced from
|
||||
// claudeTokens in useWC), so the window stops looking built-separate.
|
||||
shadow: ClaudeTokens['shadow'];
|
||||
radius: ClaudeTokens['radius'];
|
||||
border: ClaudeTokens['border'];
|
||||
}
|
||||
|
||||
// The identity half of the palette: accent, text, status, hairlines. The neutral
|
||||
// surfaces + structural primitives are blended in from claudeTokens by useWC(),
|
||||
// so the window is the same material as every other card.
|
||||
type WCColors = Omit<WCPalette, 'shadow' | 'radius' | 'border' | 'paper' | 'page' | 'panel' | 'rail' | 'inset' | 'raised'>;
|
||||
|
||||
export const WC_LIGHT: WCColors = {
|
||||
accent: '#C25A36',
|
||||
ink: '#211E1B',
|
||||
ink2: '#2B2722',
|
||||
ink3: '#4B463E',
|
||||
ink4: '#6B655C',
|
||||
muted: '#73726C',
|
||||
muted2: '#8C857A',
|
||||
faint: '#A39C92',
|
||||
inkRGB: '33,30,27',
|
||||
line: 'rgba(33,30,27,0.07)',
|
||||
line2: 'rgba(33,30,27,0.12)',
|
||||
hover: 'rgba(33,30,27,0.045)',
|
||||
selBg: 'rgba(33,30,27,0.06)',
|
||||
success: '#2E7D5B',
|
||||
successBg: 'rgba(46,125,91,0.12)',
|
||||
danger: '#C2483A',
|
||||
dangerBg: 'rgba(194,72,58,0.10)',
|
||||
warn: '#B98A2E',
|
||||
warnBg: 'rgba(185,138,46,0.14)',
|
||||
trackOff: '#D5D1C8',
|
||||
};
|
||||
|
||||
export const WC_DARK: WCColors = {
|
||||
accent: '#C25A36',
|
||||
ink: '#F2EFE9',
|
||||
ink2: '#E2DDD4',
|
||||
ink3: '#B3ABA0',
|
||||
ink4: '#9A9389',
|
||||
muted: '#938C82',
|
||||
muted2: '#736D64',
|
||||
faint: '#6E6860',
|
||||
inkRGB: '240,236,228',
|
||||
line: 'rgba(240,236,228,0.07)',
|
||||
line2: 'rgba(240,236,228,0.12)',
|
||||
hover: 'rgba(240,236,228,0.045)',
|
||||
selBg: 'rgba(240,236,228,0.06)',
|
||||
success: '#2E7D5B',
|
||||
successBg: 'rgba(46,125,91,0.22)',
|
||||
danger: '#C2483A',
|
||||
dangerBg: 'rgba(194,72,58,0.18)',
|
||||
warn: '#B98A2E',
|
||||
warnBg: 'rgba(185,138,46,0.18)',
|
||||
trackOff: 'rgba(240,236,228,0.18)',
|
||||
};
|
||||
|
||||
export function useWC(): WCPalette {
|
||||
const { mode } = useThemeMode();
|
||||
const c = useClaudeTokens();
|
||||
const base = mode === 'dark' ? WC_DARK : WC_LIGHT;
|
||||
return {
|
||||
...base,
|
||||
// Three-tone depth from the app tokens (matches the Claude design): the
|
||||
// content area sits on `page`, cards/title bar pop on `surface` above it,
|
||||
// and the sidebar/right rail recede on `secondary`. surface > page in BOTH
|
||||
// themes, so cards always pop (no light/dark inversion).
|
||||
// Light's palette inverts (elevated is lighter than surface in light, darker
|
||||
// in dark), so the mid content tone must flip per mode: in light it steps UP
|
||||
// to bg.elevated so the window stands apart from the bg.page canvas behind it
|
||||
// and cards still pop on bg.surface above; in dark bg.page is already darker
|
||||
// than the cards, so it stays. Rows mirror it (surface in light, elevated in
|
||||
// dark) so they always sit above the content.
|
||||
paper: c.bg.surface, // cards, title bar, popovers
|
||||
page: mode === 'dark' ? c.bg.secondary : c.bg.elevated, // content area: one step above the canvas in both modes
|
||||
panel: c.bg.surface, // title bar
|
||||
rail: mode === 'dark' ? c.bg.page : c.bg.secondary, // sidebar / right rail: the recessed darkest column
|
||||
inset: c.bg.page,
|
||||
raised: mode === 'dark' ? c.bg.elevated : c.bg.surface, // extra-raised rows / dropdowns
|
||||
shadow: c.shadow,
|
||||
radius: c.radius,
|
||||
border: c.border,
|
||||
};
|
||||
}
|
||||
|
||||
export const FONT_SERIF = "'Newsreader', Georgia, serif";
|
||||
export const FONT_SANS = "'Hanken Grotesk', system-ui, sans-serif";
|
||||
export const FONT_MONO = "'JetBrains Mono', ui-monospace, monospace";
|
||||
|
||||
// Stable per-workflow color: the backend has no color field, so derive a
|
||||
// vivid-but-deterministic swatch from the id. Same id always lands the same
|
||||
// hue, so dots/bars stay consistent across panes without persistence.
|
||||
export const WORKFLOW_PALETTE = [
|
||||
'#C25A36', '#3F8E83', '#5B6CB8', '#9A5B86',
|
||||
'#B5852E', '#C2483A', '#4B7A4B', '#4B463E',
|
||||
];
|
||||
|
||||
export function colorForId(id: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < id.length; i += 1) h = (h * 31 + id.charCodeAt(i)) >>> 0;
|
||||
return WORKFLOW_PALETTE[h % WORKFLOW_PALETTE.length];
|
||||
}
|
||||
|
||||
// Prefer the user's chosen swatch; fall back to the stable id-hash hue when
|
||||
// they haven't picked one. Single source of truth for every dot/bar.
|
||||
export function colorForWorkflow(wf: { id: string; color?: string | null }): string {
|
||||
return wf.color || colorForId(wf.id);
|
||||
}
|
||||
|
||||
export type RunStatus = 'success' | 'failure' | 'ran_late' | 'running' | 'skipped' | 'paused';
|
||||
|
||||
export function statusChip(status: RunStatus, wc: WCPalette): CSSProperties {
|
||||
const map: Record<string, [string, string]> = {
|
||||
success: [wc.success, wc.successBg],
|
||||
ran_late: [wc.warn, wc.warnBg],
|
||||
failure: [wc.danger, wc.dangerBg],
|
||||
skipped: [wc.muted, `rgba(${wc.inkRGB},0.07)`],
|
||||
running: [wc.accent, `rgba(${wc.inkRGB},0.06)`],
|
||||
paused: [wc.muted, `rgba(${wc.inkRGB},0.07)`],
|
||||
};
|
||||
const [color, background] = map[status] || map.paused;
|
||||
return {
|
||||
fontSize: 11, fontWeight: 600, color, background,
|
||||
padding: '3px 9px', borderRadius: 999, whiteSpace: 'nowrap', flex: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
export function statusDot(status: RunStatus, wc: WCPalette): CSSProperties {
|
||||
const map: Record<string, string> = {
|
||||
success: wc.success, ran_late: wc.warn, failure: wc.danger,
|
||||
running: wc.accent, skipped: wc.faint, paused: wc.faint,
|
||||
};
|
||||
return { width: 8, height: 8, borderRadius: '50%', background: map[status] || wc.faint, flex: 'none' };
|
||||
}
|
||||
|
||||
export function track(on: boolean, wc: WCPalette): CSSProperties {
|
||||
return {
|
||||
width: 34, height: 20, borderRadius: 999, background: on ? wc.accent : wc.trackOff,
|
||||
position: 'relative', cursor: 'pointer', transition: 'background .15s', flex: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
export function knob(on: boolean): CSSProperties {
|
||||
return {
|
||||
position: 'absolute', top: 2, left: on ? 16 : 2, width: 16, height: 16, borderRadius: '50%',
|
||||
background: '#fff', transition: 'left .15s', boxShadow: '0 1px 2px rgba(0,0,0,.25)',
|
||||
};
|
||||
}
|
||||
|
||||
export function statusLabel(status: RunStatus): string {
|
||||
switch (status) {
|
||||
case 'success': return 'Success';
|
||||
case 'failure': return 'Failed';
|
||||
case 'ran_late': return 'Ran late';
|
||||
case 'running': return 'Running';
|
||||
case 'skipped': return 'Skipped';
|
||||
default: return 'Paused';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
export interface CalendarOccurrence {
|
||||
workflowId: string;
|
||||
at: Date;
|
||||
}
|
||||
|
||||
// Single source of truth for "when does a workflow fire": the backend's
|
||||
// timezone-aware recurrence engine via /workflows/calendar. The frontend used
|
||||
// to recompute this in JS (timezone-naive, missing last-day-of-month), so the
|
||||
// calendar disagreed with what actually ran. Fetch the real instants instead.
|
||||
//
|
||||
// Keeps the current events until a fetch for a NEW window lands so a schedule
|
||||
// edit (same window, new fingerprint) refetches without blanking; only a
|
||||
// window change clears, since those events are for the wrong span.
|
||||
export function useCalendarOccurrences(fromIso: string, toIso: string): {
|
||||
events: CalendarOccurrence[];
|
||||
loaded: boolean;
|
||||
} {
|
||||
const items = useAppSelector((s) => s.workflows.items);
|
||||
// Fingerprint only the fields that change which occurrences exist. NOT
|
||||
// updated_at: the scheduler bumps it every tick and would churn the fetch.
|
||||
const scheduleKey = useMemo(
|
||||
() => Object.values(items)
|
||||
.map((w) => `${w.id}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.day_of_month ?? ''}:${w.schedule.last_day_of_month ?? ''}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[items],
|
||||
);
|
||||
|
||||
const windowKey = `${fromIso}:${toIso}`;
|
||||
const requestKey = `${windowKey}:${scheduleKey}`;
|
||||
const [events, setEvents] = useState<CalendarOccurrence[]>([]);
|
||||
const [fetchedWindowKey, setFetchedWindowKey] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// No AbortController: the global fetch interceptor dedupes GETs by URL onto
|
||||
// one request, so aborting on re-run (as workflows hydrate) would reject the
|
||||
// shared request. The `cancelled` guard stops stale state writes instead.
|
||||
let cancelled = false;
|
||||
fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`calendar failed ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data: { events?: { workflow_id: string; fire_at: string }[] }) => {
|
||||
if (cancelled) return;
|
||||
const parsed: CalendarOccurrence[] = [];
|
||||
for (const e of data.events || []) {
|
||||
const at = new Date(e.fire_at);
|
||||
if (!Number.isNaN(at.getTime())) parsed.push({ workflowId: e.workflow_id, at });
|
||||
}
|
||||
setEvents(parsed);
|
||||
setFetchedWindowKey(windowKey);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setFetchedWindowKey(windowKey);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fromIso, toIso, requestKey]);
|
||||
|
||||
// Events from a previous window are for the wrong span: hide them until the
|
||||
// new window's fetch resolves.
|
||||
if (fetchedWindowKey !== windowKey) return { events: [], loaded: false };
|
||||
return { events, loaded: true };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { fetchSession } from '@/shared/state/agentsSlice';
|
||||
import { ensureEditAgentSession } from './api';
|
||||
|
||||
// Boots (or reattaches) the sticky edit-agent session for a workflow and returns
|
||||
// its id once ready. The opener is deterministic: an existing workflow gets a
|
||||
// fixed intro message from the backend; a brand-new build starts empty and the
|
||||
// compose page shows its own starter prompts.
|
||||
export function useEditAgentSession(workflowId: string): string | null {
|
||||
const dispatch = useAppDispatch();
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const didInit = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
didInit.current = false;
|
||||
setSessionId(null);
|
||||
}, [workflowId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workflowId || didInit.current) return;
|
||||
didInit.current = true;
|
||||
let alive = true;
|
||||
(async () => {
|
||||
const sid = await ensureEditAgentSession(workflowId);
|
||||
if (!sid || !alive) return;
|
||||
try { await dispatch(fetchSession(sid)).unwrap(); } catch { /* may hydrate later */ }
|
||||
if (alive) setSessionId(sid);
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [workflowId, dispatch]);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { updateWorkflow, fetchWorkflows } from '@/shared/state/workflowsSlice';
|
||||
import type { Workflow } from '@/shared/state/workflowsSlice';
|
||||
|
||||
// Patch a workflow with optimistic concurrency. The PATCH carries If-Match on
|
||||
// updated_at; if the record changed underneath us (409 → 'stale'), resync from
|
||||
// the server so the next edit starts from truth instead of stomping it.
|
||||
export function useWorkflowPatch() {
|
||||
const dispatch = useAppDispatch();
|
||||
return useCallback((wf: Workflow, patch: Partial<Workflow>) => {
|
||||
dispatch(updateWorkflow({ id: wf.id, patch, ifMatch: wf.updated_at }))
|
||||
.unwrap()
|
||||
.catch((err: { kind?: string } | undefined) => {
|
||||
if (err?.kind === 'stale') dispatch(fetchWorkflows(wf.dashboard_id ?? undefined));
|
||||
});
|
||||
}, [dispatch]);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { API_BASE, getAuthToken } from '@/shared/config';
|
||||
import type { WorkflowStep } from '@/shared/state/workflowsSlice';
|
||||
import { stepsSignature } from './scheduleUtils';
|
||||
|
||||
type OpenSidecar = (sessionId: string, kind: 'testing') => Promise<void>;
|
||||
|
||||
// Kick off a Test Agent run for the given (possibly-draft) steps and wire its
|
||||
// session into the workflow card's sidecar. The signature rides along so a
|
||||
// completed test run stamps the workflow as validated (see scheduleUtils +
|
||||
// the test-run endpoint). Returns the session id, or null if it didn't start.
|
||||
export async function runWorkflowTest(
|
||||
workflowId: string,
|
||||
steps: WorkflowStep[],
|
||||
openSidecar: OpenSidecar,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflowId)}/test-run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
|
||||
body: JSON.stringify({ steps, signature: stepsSignature(steps) }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const sid = data?.session_id as string | undefined;
|
||||
if (!sid) return null;
|
||||
await openSidecar(sid, 'testing');
|
||||
return sid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Workflow, ScheduleConfig, WorkflowStep } from '@/shared/state/workflowsSlice';
|
||||
|
||||
export const WEEKDAY_LABEL = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||
export const WEEKDAY_LABEL_SHORT = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
|
||||
export const WEEKDAY_FULL = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
export function defaultSchedule(): ScheduleConfig {
|
||||
// Pick the host's IANA tz so new schedules start with an explicit zone
|
||||
// instead of the legacy "local" sentinel. Backend storage still coerces
|
||||
// "local" if a record predates this default; new records skip that path.
|
||||
let tz = 'local';
|
||||
try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'local'; } catch { /* keep 'local' */ }
|
||||
return {
|
||||
enabled: false,
|
||||
repeat_every: 1,
|
||||
repeat_unit: 'week',
|
||||
on_days: [],
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
day_of_month: null,
|
||||
timezone: tz,
|
||||
ends_at: null,
|
||||
max_runs: null,
|
||||
runs_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function isScheduleConfigured(sched: ScheduleConfig | null | undefined): boolean {
|
||||
if (!sched) return false;
|
||||
if (sched.repeat_unit === 'week') return sched.on_days.length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isScheduleActive(sched: ScheduleConfig | null | undefined): boolean {
|
||||
return !!sched?.enabled && isScheduleConfigured(sched);
|
||||
}
|
||||
|
||||
export function isWorkflowSchedulable(workflow: Workflow): boolean {
|
||||
return isScheduleConfigured(workflow.schedule);
|
||||
}
|
||||
|
||||
// Stable fingerprint of the steps that actually drive behavior (order + id +
|
||||
// text). label is just the at-a-glance headline, so it's left out. Computed
|
||||
// only here so the backend stores exactly what the FE compares: no cross-
|
||||
// language hashing drift.
|
||||
export function stepsSignature(steps: WorkflowStep[] | null | undefined): string {
|
||||
return JSON.stringify((steps || []).map((s) => [s.id, s.text]));
|
||||
}
|
||||
|
||||
// True when the current steps haven't been validated by a test run (or seeded
|
||||
// at chat conversion) since they were last edited. Drives the test-first
|
||||
// warning before scheduling.
|
||||
export function needsScheduleTestWarning(workflow: Workflow): boolean {
|
||||
const steps = workflow.draft_steps ?? workflow.steps;
|
||||
if (!steps || steps.length === 0) return false;
|
||||
return stepsSignature(steps) !== (workflow.tested_signature ?? '');
|
||||
}
|
||||
|
||||
export function formatTime(hour: number, minute: number): string {
|
||||
const h12 = ((hour + 11) % 12) + 1;
|
||||
const suffix = hour < 12 ? 'am' : 'pm';
|
||||
const mm = String(minute).padStart(2, '0');
|
||||
return minute === 0 ? `${h12}${suffix}` : `${h12}:${mm}${suffix}`;
|
||||
}
|
||||
|
||||
// Used in the roomy hub calendar: "10 AM", "12 PM", "1 PM"...
|
||||
// Matches Figma image #8 styling for the left-column time labels.
|
||||
export function formatHourLabel(hour: number): string {
|
||||
const h12 = ((hour + 11) % 12) + 1;
|
||||
const suffix = hour < 12 ? 'AM' : 'PM';
|
||||
return `${h12} ${suffix}`;
|
||||
}
|
||||
|
||||
export function describeSchedule(sched: ScheduleConfig): string {
|
||||
if (!sched.enabled || !isScheduleConfigured(sched)) return 'Not scheduled';
|
||||
const time = formatTime(sched.hour, sched.minute);
|
||||
if (sched.repeat_unit === 'minute') {
|
||||
return `Every ${sched.repeat_every} minutes`;
|
||||
}
|
||||
if (sched.repeat_unit === 'hour') {
|
||||
const at = sched.minute === 0 ? '' : ` at :${String(sched.minute).padStart(2, '0')}`;
|
||||
return sched.repeat_every === 1 ? `Every hour${at}` : `Every ${sched.repeat_every} hours${at}`;
|
||||
}
|
||||
if (sched.repeat_unit === 'day') {
|
||||
return sched.repeat_every === 1 ? `Every day at ${time}` : `Every ${sched.repeat_every} days at ${time}`;
|
||||
}
|
||||
if (sched.repeat_unit === 'month') {
|
||||
const day = sched.day_of_month ? ` on day ${sched.day_of_month}` : '';
|
||||
return sched.repeat_every === 1 ? `Every month${day} at ${time}` : `Every ${sched.repeat_every} months${day} at ${time}`;
|
||||
}
|
||||
const days = sched.on_days.length === 0 ? 'week' : sched.on_days
|
||||
.slice()
|
||||
.sort()
|
||||
.map((d) => WEEKDAY_FULL[d])
|
||||
.join(', ');
|
||||
const cadence = sched.repeat_every === 1 ? `Every ${days}` : `Every ${sched.repeat_every} weeks on ${days}`;
|
||||
return `${cadence} at ${time}`;
|
||||
}
|
||||
|
||||
export function describePermissions(workflow: Workflow): string {
|
||||
if (!workflow.permissions || workflow.permissions.length === 0) return 'Notify only';
|
||||
const labels: string[] = [];
|
||||
for (const p of workflow.permissions) {
|
||||
if (p.kind === 'notify') labels.push('notify in app');
|
||||
else if (p.kind === 'text') labels.push('text');
|
||||
else if (p.kind === 'call') labels.push('call');
|
||||
}
|
||||
return `First ${labels.join(', then ')}`;
|
||||
}
|
||||
|
||||
export function startOfWeek(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
d.setDate(d.getDate() - d.getDay());
|
||||
return d;
|
||||
}
|
||||
|
||||
export function startOfMonthGrid(date: Date): Date {
|
||||
const d = new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
d.setDate(d.getDate() - d.getDay());
|
||||
return d;
|
||||
}
|
||||
|
||||
export function sameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
export function addDays(date: Date, n: number): Date {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + n);
|
||||
return d;
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
// Shared visual helpers for the workflow card UI tier: schedule/permission
|
||||
// pill chips, status dot, run-status sparkline, step connector, step icon
|
||||
// auto-classifier. Kept as plain functions/components so individual views
|
||||
// can compose without owning the styling.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { updateWorkflow } from '@/shared/state/workflowsSlice';
|
||||
import ScheduleIcon from '@mui/icons-material/ScheduleRounded';
|
||||
import NotificationsIcon from '@mui/icons-material/NotificationsRounded';
|
||||
import SmsIcon from '@mui/icons-material/SmsRounded';
|
||||
import PhoneInTalkIcon from '@mui/icons-material/PhoneInTalkRounded';
|
||||
import EmailIcon from '@mui/icons-material/MailOutlineRounded';
|
||||
import EventNoteIcon from '@mui/icons-material/EventNoteRounded';
|
||||
import ChromeReaderModeIcon from '@mui/icons-material/ChromeReaderModeRounded';
|
||||
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
||||
import CalendarTodayIcon from '@mui/icons-material/CalendarTodayRounded';
|
||||
import ArticleIcon from '@mui/icons-material/ArticleRounded';
|
||||
import LanguageIcon from '@mui/icons-material/LanguageRounded';
|
||||
import AttachMoneyIcon from '@mui/icons-material/AttachMoneyRounded';
|
||||
import AllInclusiveIcon from '@mui/icons-material/AllInclusiveRounded';
|
||||
import CodeIcon from '@mui/icons-material/CodeRounded';
|
||||
import SearchIcon from '@mui/icons-material/SearchRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { Workflow, WorkflowRun, ScheduleConfig, PermissionTier } from '@/shared/state/workflowsSlice';
|
||||
import { formatTime, WEEKDAY_LABEL, isScheduleConfigured } from './scheduleUtils';
|
||||
|
||||
// ---------- Title placeholders ----------
|
||||
|
||||
// Placeholder titles the backend uses before auto-naming kicks in. The title
|
||||
// Typewriter only animates once the title is a real (generated/user) name, so
|
||||
// the UI doesn't animate on mount or while still showing a placeholder.
|
||||
const PLACEHOLDER_TITLES = new Set(['', 'New workflow', 'Untitled workflow', 'Scheduled workflow']);
|
||||
export function isRealTitle(title?: string | null): boolean {
|
||||
return !!title && !PLACEHOLDER_TITLES.has(title.trim());
|
||||
}
|
||||
|
||||
// ---------- Status colors ----------
|
||||
|
||||
export type LastRunStatus = NonNullable<Workflow['last_run_status']>;
|
||||
|
||||
export function statusDotColor(status: LastRunStatus | null | undefined, c: ReturnType<typeof useClaudeTokens>) {
|
||||
switch (status) {
|
||||
case 'success': return c.status.success;
|
||||
case 'ran_late': return c.status.warning;
|
||||
case 'failure': return c.status.error;
|
||||
case 'running': return c.accent.primary;
|
||||
case 'skipped': return c.text.muted;
|
||||
default: return c.text.ghost;
|
||||
}
|
||||
}
|
||||
|
||||
// Human-readable status word. We surface "ran late" instead of the
|
||||
// underscore-y "ran_late" everywhere it'd be visible to a user.
|
||||
export function statusWord(status: LastRunStatus | null | undefined): string {
|
||||
if (!status) return 'Never run';
|
||||
if (status === 'ran_late') return 'Ran late';
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
// Status pill rendered next to the title. Bigger than the previous 9px
|
||||
// dot and pairs the color with a short word so a non-dev knows what
|
||||
// they're looking at instead of squinting at a single grey pixel.
|
||||
export function StatusDot({ status }: { status: LastRunStatus | null | undefined }) {
|
||||
const c = useClaudeTokens();
|
||||
const word = statusWord(status);
|
||||
const dotColor = statusDotColor(status, c);
|
||||
return (
|
||||
<Tooltip title={status ? `Last run: ${word.toLowerCase()}` : 'This workflow has never run.'}>
|
||||
<Box sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
height: 18, px: 0.75, borderRadius: c.radius.full,
|
||||
bgcolor: status === 'failure' ? c.status.errorBg : status === 'ran_late' ? c.status.warningBg : status === 'success' ? c.status.successBg : c.bg.elevated,
|
||||
border: `1px solid ${dotColor}55`,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: dotColor, boxShadow: status === 'failure' ? `0 0 4px ${c.status.error}` : 'none' }} />
|
||||
<Typography sx={{ fontSize: '0.66rem', fontWeight: 700, color: dotColor, letterSpacing: '0.02em' }}>
|
||||
{word}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Pill chips ----------
|
||||
|
||||
function scheduleShort(sched: ScheduleConfig): string {
|
||||
if (!sched.enabled || !isScheduleConfigured(sched)) return 'Not scheduled';
|
||||
const time = formatTime(sched.hour, sched.minute);
|
||||
if (sched.repeat_unit === 'minute') return `Every ${sched.repeat_every}m`;
|
||||
if (sched.repeat_unit === 'hour') return sched.repeat_every === 1 ? 'Hourly' : `Every ${sched.repeat_every}h`;
|
||||
if (sched.repeat_unit === 'day') {
|
||||
return sched.repeat_every === 1 ? `Daily ${time}` : `Every ${sched.repeat_every}d ${time}`;
|
||||
}
|
||||
if (sched.repeat_unit === 'month') {
|
||||
const day = sched.day_of_month ? ` day ${sched.day_of_month}` : '';
|
||||
return sched.repeat_every === 1 ? `Monthly${day} ${time}` : `Every ${sched.repeat_every}mo${day} ${time}`;
|
||||
}
|
||||
if (sched.on_days.length === 5 && [1, 2, 3, 4, 5].every((d) => sched.on_days.includes(d))) return `Weekdays ${time}`;
|
||||
if (sched.on_days.length === 2 && [0, 6].every((d) => sched.on_days.includes(d))) return `Weekends ${time}`;
|
||||
if (sched.on_days.length === 1) {
|
||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return `${labels[sched.on_days[0]]} ${time}`;
|
||||
}
|
||||
return `${sched.on_days.length}×/wk ${time}`;
|
||||
}
|
||||
|
||||
// Weekday-dot strip "S M T W T F S" with active days filled. Rendered
|
||||
// inline next to the chip when the schedule is weekly so users can
|
||||
// pattern-match days without parsing prose. Active = filled accent dot.
|
||||
export function WeekdayDots({ on_days }: { on_days: number[] }) {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, ml: 0.5 }}>
|
||||
{WEEKDAY_LABEL.map((lbl, idx) => {
|
||||
const active = on_days.includes(idx);
|
||||
return (
|
||||
<Box key={`${lbl}-${idx}`} sx={{
|
||||
width: 12, height: 12, borderRadius: '50%',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '0.6rem', fontWeight: 700,
|
||||
color: active ? '#fff' : c.text.ghost,
|
||||
bgcolor: active ? c.accent.primary : 'transparent',
|
||||
border: `1px solid ${active ? c.accent.primary : c.border.subtle}`,
|
||||
lineHeight: 1,
|
||||
}}>
|
||||
{lbl}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function permIcon(kind: PermissionTier['kind'], size = 13) {
|
||||
if (kind === 'text') return <SmsIcon sx={{ fontSize: size }} />;
|
||||
if (kind === 'call') return <PhoneInTalkIcon sx={{ fontSize: size }} />;
|
||||
return <NotificationsIcon sx={{ fontSize: size }} />;
|
||||
}
|
||||
|
||||
// Compact "🔔 → 💬 → 📞" representation of the escalation chain. Hover
|
||||
// shows the literal prose (notify, text, call, with delays).
|
||||
export function PermissionChip({ workflow }: { workflow: Workflow }) {
|
||||
const c = useClaudeTokens();
|
||||
const tiers = workflow.permissions || [];
|
||||
if (tiers.length === 0) return null;
|
||||
const label = tiers.map((t) => {
|
||||
if (t.kind === 'notify') return 'notify in app';
|
||||
const unit = t.kind === 'call' ? 'h' : 'm';
|
||||
return `${t.kind} after ${t.after_minutes}${unit}`;
|
||||
}).join(' → ');
|
||||
return (
|
||||
<Tooltip title={label}>
|
||||
<Box sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.35,
|
||||
fontSize: '0.74rem', fontWeight: 500,
|
||||
color: c.text.secondary,
|
||||
bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
px: 0.75, py: 0.3, borderRadius: c.radius.full,
|
||||
}}>
|
||||
{tiers.map((t, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{permIcon(t.kind)}
|
||||
{i < tiers.length - 1 && <Box sx={{ fontSize: '0.7rem', color: c.text.ghost, mx: 0.1 }}>→</Box>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleChip({ workflow }: { workflow: Workflow }) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const enabled = workflow.schedule.enabled && isScheduleConfigured(workflow.schedule);
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
// Inline edit: time + AM/PM only. Anything richer should open the
|
||||
// full editor. Saves on change with optimistic updated_at If-Match.
|
||||
const sched = workflow.schedule;
|
||||
const patchSched = (patch: Partial<typeof sched>) => {
|
||||
const next = { ...sched, ...patch };
|
||||
dispatch(updateWorkflow({
|
||||
id: workflow.id,
|
||||
patch: { schedule: next as any },
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={enabled ? `Click to tweak time. Full editor lives in the Edit tab.` : 'Not scheduled'}>
|
||||
<Box
|
||||
onClick={(e) => enabled && setAnchor(e.currentTarget as HTMLElement)}
|
||||
role={enabled ? 'button' : undefined}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
fontSize: '0.74rem', fontWeight: 600,
|
||||
color: enabled ? c.accent.primary : c.text.muted,
|
||||
bgcolor: enabled ? c.accent.primary + '14' : c.bg.elevated,
|
||||
border: `1px solid ${enabled ? c.accent.primary + '40' : c.border.subtle}`,
|
||||
px: 0.75, py: 0.3, borderRadius: c.radius.full,
|
||||
cursor: enabled ? 'pointer' : 'default',
|
||||
'&:hover': enabled ? { bgcolor: c.accent.primary + '22' } : undefined,
|
||||
}}>
|
||||
<ScheduleIcon sx={{ fontSize: 13 }} />
|
||||
{scheduleShort(workflow.schedule)}
|
||||
{enabled && workflow.schedule.repeat_unit === 'week' && (
|
||||
<WeekdayDots on_days={workflow.schedule.on_days} />
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
open={Boolean(anchor)}
|
||||
anchorEl={anchor}
|
||||
onClose={() => setAnchor(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}>
|
||||
<Box sx={{ p: 1, display: 'flex', flexDirection: 'column', gap: 0.5, minWidth: 220 }}>
|
||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em' }}>
|
||||
QUICK TIME EDIT
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Select
|
||||
size="small"
|
||||
value={((sched.hour + 11) % 12) + 1}
|
||||
onChange={(e) => {
|
||||
const h12 = Number(e.target.value);
|
||||
const isPm = sched.hour >= 12;
|
||||
patchSched({ hour: (h12 % 12) + (isPm ? 12 : 0) });
|
||||
}}
|
||||
sx={{ fontSize: '0.78rem', '& .MuiSelect-select': { py: 0.4 } }}>
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((h) => (
|
||||
<MenuItem key={h} value={h}>{h}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Typography sx={{ fontSize: '0.85rem' }}>:</Typography>
|
||||
<Select
|
||||
size="small"
|
||||
value={sched.minute}
|
||||
onChange={(e) => patchSched({ minute: Number(e.target.value) })}
|
||||
sx={{ fontSize: '0.78rem', '& .MuiSelect-select': { py: 0.4 } }}>
|
||||
{[0, 15, 30, 45].map((m) => (
|
||||
<MenuItem key={m} value={m}>{String(m).padStart(2, '0')}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size="small"
|
||||
value={sched.hour < 12 ? 'AM' : 'PM'}
|
||||
onChange={(e) => {
|
||||
const wasPm = sched.hour >= 12;
|
||||
const willBePm = e.target.value === 'PM';
|
||||
if (wasPm === willBePm) return;
|
||||
patchSched({ hour: willBePm ? sched.hour + 12 : sched.hour - 12 });
|
||||
}}
|
||||
sx={{ fontSize: '0.78rem', '& .MuiSelect-select': { py: 0.4 } }}>
|
||||
<MenuItem value="AM">AM</MenuItem>
|
||||
<MenuItem value="PM">PM</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost, mt: 0.25 }}>
|
||||
Saved as you change.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Classify a workflow's billing route based on its model id + the user's
|
||||
// global connection mode. Mirrors the per-session logic in AgentChat so
|
||||
// the workflow card tells the same story the chat header does. Returns
|
||||
// 'metered' when the user pays per call (Anthropic/OpenAI/Gemini API
|
||||
// keys, custom OpenAI-compatible) or 'subscription' when a flat-rate
|
||||
// account is doing the work (Claude Pro/Max, ChatGPT Plus/Pro, Gemini
|
||||
// Advanced, OpenSwarm Pro proxy). `subLabel` names the plan for tooltips.
|
||||
export type RoutingKind = 'metered' | 'subscription';
|
||||
export interface Routing {
|
||||
kind: RoutingKind;
|
||||
subLabel?: string;
|
||||
}
|
||||
|
||||
export function routingFor(model: string, connectionMode: string | undefined): Routing {
|
||||
const m = (model || '').toLowerCase();
|
||||
if (m.endsWith('-api')) return { kind: 'metered' };
|
||||
if (m.endsWith('-cc')) return { kind: 'subscription', subLabel: 'Claude Pro/Max' };
|
||||
const isPlainAnthropic = m === 'sonnet' || m === 'opus' || m === 'haiku';
|
||||
if (isPlainAnthropic && connectionMode === 'openswarm-pro') {
|
||||
return { kind: 'subscription', subLabel: 'OpenSwarm Pro' };
|
||||
}
|
||||
if (isPlainAnthropic) return { kind: 'metered' };
|
||||
if (m.startsWith('gpt-5') || m.startsWith('gpt-4') || m.startsWith('o1') || m.startsWith('o3') || m.startsWith('o4')) {
|
||||
return { kind: 'subscription', subLabel: 'ChatGPT Plus/Pro' };
|
||||
}
|
||||
if (m.startsWith('gemini-')) {
|
||||
return { kind: 'subscription', subLabel: 'Gemini Advanced' };
|
||||
}
|
||||
// Unknown model id, default to metered so we don't oversell "free."
|
||||
return { kind: 'metered' };
|
||||
}
|
||||
|
||||
export function CostChip({ workflow, connectionMode }: { workflow: Workflow; connectionMode?: string }) {
|
||||
const c = useClaudeTokens();
|
||||
const est = workflow.cost_estimate;
|
||||
const route = routingFor(workflow.model, connectionMode);
|
||||
|
||||
// Subscription-routed workflows have no metered per-call cost. Surface
|
||||
// a usage chip instead so the user knows runs are "free" under their
|
||||
// existing plan but still sees the projected fire frequency.
|
||||
if (route.kind === 'subscription') {
|
||||
if (!est || est.fires_per_month === 0) {
|
||||
return (
|
||||
<Tooltip title={`Runs are covered by your ${route.subLabel} plan. No upcoming runs scheduled.`}>
|
||||
<Box sx={chipSx(c)}>
|
||||
<AllInclusiveIcon sx={{ fontSize: 12 }} />
|
||||
{route.subLabel || 'Subscription'}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip title={`Routed through your ${route.subLabel} plan; no per-run cost. About ${est.fires_per_month} runs per month at the current schedule.`}>
|
||||
<Box sx={chipSx(c)}>
|
||||
<AllInclusiveIcon sx={{ fontSize: 12 }} />
|
||||
~{est.fires_per_month} runs/mo
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Metered route: only render the cost chip once we actually have a
|
||||
// last-run figure to project from. Avoids "$0.00/mo" gaslighting.
|
||||
if (!est || est.fires_per_month === 0 || est.last_run_usd <= 0) return null;
|
||||
const monthly = est.monthly_usd || 0;
|
||||
return (
|
||||
<Tooltip title={`About $${est.last_run_usd.toFixed(4)} per run, times ${est.fires_per_month} runs per month.`}>
|
||||
<Box sx={chipSx(c)}>
|
||||
<AttachMoneyIcon sx={{ fontSize: 12, ml: -0.25 }} />
|
||||
{monthly < 0.01 ? '<0.01' : monthly.toFixed(2)}/mo
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function chipSx(c: ReturnType<typeof useClaudeTokens>) {
|
||||
return {
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.3,
|
||||
fontSize: '0.74rem', fontWeight: 600,
|
||||
color: c.text.secondary,
|
||||
bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
px: 0.75, py: 0.3, borderRadius: c.radius.full,
|
||||
} as const;
|
||||
}
|
||||
|
||||
// Compact "last fired" mini-label, used inside the Run-tab summary.
|
||||
export function LastFiredHint({ workflow }: { workflow: Workflow }) {
|
||||
const c = useClaudeTokens();
|
||||
if (!workflow.last_run_at) return null;
|
||||
const ms = Date.now() - new Date(workflow.last_run_at).getTime();
|
||||
const ago = relTime(ms);
|
||||
return (
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost }}>Last ran {ago}</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
function relTime(ms: number): string {
|
||||
if (ms < 0) return 'just now';
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d}d ago`;
|
||||
const mo = Math.floor(d / 30);
|
||||
return `${mo}mo ago`;
|
||||
}
|
||||
|
||||
// ---------- Run history sparkline ----------
|
||||
|
||||
// 10-dot horizontal strip of last N runs colored by status. Easy "lately
|
||||
// healthy?" check without opening the History tab.
|
||||
export function RunSparkline({ runs, max = 10 }: { runs: WorkflowRun[]; max?: number }) {
|
||||
const c = useClaudeTokens();
|
||||
if (!runs || runs.length === 0) return null;
|
||||
const slice = runs.slice(0, max).reverse();
|
||||
const successes = slice.filter((r) => r.status === 'success').length;
|
||||
const failures = slice.filter((r) => r.status === 'failure').length;
|
||||
const tooltip = `Last ${slice.length} run${slice.length === 1 ? '' : 's'}: ${successes} successful, ${failures} failed`;
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.3, ml: 0.5 }}>
|
||||
{slice.map((r) => (
|
||||
<Box key={r.id} sx={{
|
||||
width: 6, height: 6, borderRadius: '50%',
|
||||
bgcolor: statusDotColor(r.status as LastRunStatus, c),
|
||||
}} />
|
||||
))}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Streak badge ----------
|
||||
|
||||
// Count consecutive successful runs at the head of the runs list.
|
||||
// `runs[0]` is the most recent run, so we walk forward until we hit a
|
||||
// non-success. Returns 0 when no streak is active.
|
||||
export function successStreak(runs: WorkflowRun[] | undefined): number {
|
||||
if (!runs || runs.length === 0) return 0;
|
||||
let n = 0;
|
||||
for (const r of runs) {
|
||||
if (r.status === 'success' || r.status === 'ran_late') n += 1;
|
||||
else break;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function StreakBadge({ runs }: { runs: WorkflowRun[] | undefined }) {
|
||||
const c = useClaudeTokens();
|
||||
const n = successStreak(runs);
|
||||
if (n < 3) return null;
|
||||
return (
|
||||
<Tooltip title={`${n} successful runs in a row.`}>
|
||||
<Box sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.3,
|
||||
fontSize: '0.72rem', fontWeight: 700,
|
||||
color: c.status.success,
|
||||
bgcolor: c.status.successBg,
|
||||
border: `1px solid ${c.status.success + '60'}`,
|
||||
px: 0.75, py: 0.3, borderRadius: c.radius.full,
|
||||
}}>
|
||||
🔥 {n}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Step icon auto-classifier ----------
|
||||
|
||||
// Pick a glyph by keyword scan of the step text. Falls back to the
|
||||
// step number when nothing matches. Same Roman-numeral simple heuristic
|
||||
// the user sees: "summarize email" -> mail icon, "make notion page" ->
|
||||
// article icon, etc.
|
||||
const ICON_RULES: Array<{ pattern: RegExp; Icon: React.ElementType }> = [
|
||||
{ pattern: /\b(email|inbox|gmail|outlook|mail)\b/i, Icon: EmailIcon },
|
||||
{ pattern: /\b(calendar|schedule|event|meeting)\b/i, Icon: CalendarTodayIcon },
|
||||
{ pattern: /\b(notion|doc|page|page template|document|article)\b/i, Icon: ArticleIcon },
|
||||
{ pattern: /\b(text|sms|message|whatsapp|imessage)\b/i, Icon: SmsIcon },
|
||||
{ pattern: /\b(call|phone|dial|ring)\b/i, Icon: PhoneInTalkIcon },
|
||||
{ pattern: /\b(browser|web|website|url|fetch|visit|navigate)\b/i, Icon: LanguageIcon },
|
||||
{ pattern: /\b(search|find|look up|google)\b/i, Icon: SearchIcon },
|
||||
{ pattern: /\b(code|github|repo|script|bash|run)\b/i, Icon: CodeIcon },
|
||||
{ pattern: /\b(read|review|summarize|summary)\b/i, Icon: ChromeReaderModeIcon },
|
||||
{ pattern: /\b(chat|reply|respond|dm)\b/i, Icon: ChatBubbleOutlineIcon },
|
||||
{ pattern: /\b(note|memo|journal|log)\b/i, Icon: EventNoteIcon },
|
||||
];
|
||||
|
||||
export function stepIconFor(text: string): React.ElementType | null {
|
||||
for (const rule of ICON_RULES) {
|
||||
if (rule.pattern.test(text)) return rule.Icon;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- Step duration learner ----------
|
||||
|
||||
// Estimates per-step duration by averaging recent runs. Today we only
|
||||
// have whole-run duration on each WorkflowRun (started_at -> finished_at),
|
||||
// so the heuristic spreads it evenly across the step count. When per-step
|
||||
// telemetry lands later, swap this for a per-step lookup.
|
||||
export function estimateStepDuration(workflow: Workflow, runs: WorkflowRun[] | undefined, stepIdx: number): string | null {
|
||||
if (!runs || runs.length === 0) return null;
|
||||
const steps = workflow.steps?.length || 1;
|
||||
const successful = runs.filter((r) => (r.status === 'success' || r.status === 'ran_late') && r.finished_at);
|
||||
if (successful.length === 0) return null;
|
||||
const durations = successful.slice(0, 10).map((r) => {
|
||||
const start = new Date(r.started_at).getTime();
|
||||
const end = new Date(r.finished_at!).getTime();
|
||||
return Math.max(0, end - start);
|
||||
});
|
||||
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
|
||||
const perStepMs = avg / steps;
|
||||
void stepIdx;
|
||||
return humanDuration(perStepMs);
|
||||
}
|
||||
|
||||
export function humanDuration(ms: number): string {
|
||||
if (ms < 1000) return '<1s';
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
return rem > 0 && m < 5 ? `${m}m ${rem}s` : `${m}m`;
|
||||
}
|
||||
|
||||
// ---------- Run-button breath logic ----------
|
||||
|
||||
// Returns true when the workflow hasn't been run in over 24h. Used by
|
||||
// the Run tab to add a subtle CSS breathing animation so the button
|
||||
// invites use without yelling.
|
||||
export function isStaleSinceLastRun(workflow: Workflow): boolean {
|
||||
if (!workflow.last_run_at) return false;
|
||||
const age = Date.now() - new Date(workflow.last_run_at).getTime();
|
||||
return age > 24 * 3600 * 1000;
|
||||
}
|
||||
@@ -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