[aidan] feat/run-context: attach a run as removable chat context

This commit is contained in:
abccodes
2026-06-23 00:00:13 -07:00
parent fac7f46433
commit 76cb97b9d0
10 changed files with 242 additions and 21 deletions
+13 -4
View File
@@ -3617,8 +3617,14 @@ class AgentManager:
selected_browser_ids: list[str] | None = None,
selected_app_output_ids: list[str] | None = None,
client_message_id: str | None = None,
prepend_context: str | None = None,
):
"""Send a follow-up message to an existing session."""
"""Send a follow-up message to an existing session.
prepend_context rides along with this single turn (folded into the model
prompt) but never shows in the user's bubble, so a run transcript can be
the context for a question without a separate "I've reviewed it" turn.
"""
session = self.sessions.get(session_id)
if not session:
data = _load_session_data(session_id)
@@ -3683,6 +3689,9 @@ class AgentManager:
"message": user_msg.model_dump(mode="json"),
})
# The model sees the context prefix; the displayed user_msg above does not.
model_prompt = f"{prepend_context}\n\n{prompt}" if prepend_context else prompt
# Fire a background aux LLM call to generate a 3-6 word verb-phrase
# describing this turn ("Auditing the pull request", "Drafting your
# email"). The narrator pill swaps from its heuristic verb to this
@@ -3725,7 +3734,7 @@ class AgentManager:
# error falls through to the normal loop.
fast_verdict = "no"
fast_brief = ""
if not hidden:
if not hidden and not prepend_context:
try:
from backend.apps.agents.browser import browser_fast_path
_extras = bool(images or context_paths or forced_tools or attached_skills
@@ -3741,9 +3750,9 @@ class AgentManager:
logger.warning(f"[browser-fast-path] gate error, normal path: {e}")
if fast_verdict != "no":
task = asyncio.create_task(self._run_browser_fast_path(session_id, prompt, selected_browser_ids, fast_brief, fast_verdict))
task = asyncio.create_task(self._run_browser_fast_path(session_id, model_prompt, selected_browser_ids, fast_brief, fast_verdict))
else:
task = asyncio.create_task(self._run_agent_loop(session_id, prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills, selected_browser_ids=selected_browser_ids, selected_app_output_ids=selected_app_output_ids))
task = asyncio.create_task(self._run_agent_loop(session_id, model_prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills, selected_browser_ids=selected_browser_ids, selected_app_output_ids=selected_app_output_ids))
self.tasks[session_id] = task
async def _run_browser_fast_path(self, session_id: str, prompt: str, selected_browser_ids: list[str] | None, brief: str = "", verdict: str = "act"):
+9
View File
@@ -271,6 +271,15 @@ class MissedRunAction(BaseModel):
ids: list[str] = Field(default_factory=list)
class AskRunBody(BaseModel):
# Answer a chat question with a finished run's transcript folded in as context.
# run_id picks the run's session to pull in; prompt is the user's question.
run_id: str
prompt: str
mode: Optional[str] = None
model: Optional[str] = None
class DraftCommitBody(BaseModel):
# The model the user settled on in the Edit Agent picker, applied to the
# workflow's run model only on Save (save-gated; Discard drops it).
+44
View File
@@ -14,6 +14,7 @@ from backend.apps.workflows.models import (
WorkflowRun,
WorkflowStep,
DraftCommitBody,
AskRunBody,
MissedRunAction,
GenerateMetadataRequest,
GenerateMetadataResponse,
@@ -979,6 +980,49 @@ async def edit_agent_session(workflow_id: str):
return {"session_id": session.id}
@workflows.router.post("/{workflow_id}/ask-run")
async def ask_run(workflow_id: str, body: AskRunBody):
"""Answer a chat question with a run's transcript folded in as context for
that one turn. The run rides along hidden (prepend_context), so the user's
bubble shows just their question and there's no extra "reviewed it" turn.
"""
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
run = next((r for r in storage.list_runs(workflow_id, limit=200) if r.id == body.run_id), None)
if not run or not run.session_id:
raise HTTPException(status_code=404, detail="Run has no chat to attach")
from backend.apps.agents.agent_manager import agent_manager
sess = agent_manager.sessions.get(run.session_id)
if sess is None:
try:
sess = await agent_manager.resume_session(run.session_id)
except ValueError:
sess = None
transcript = p_render_test_transcript(getattr(sess, "messages", []) or []) if sess else ""
edit = await edit_agent_session(workflow_id)
edit_sid = edit["session_id"]
status_word = {
"success": "completed", "ran_late": "completed (late)", "failure": "failed",
}.get(run.status, run.status)
context = (
f"The user is asking about a run of this workflow (run {status_word}). Use the run's full "
f"transcript below, including each step's tool calls and results, to answer their question. "
f"Do not summarize unless asked.\n\n=== RUN TRANSCRIPT ===\n{transcript or '(transcript unavailable)'}\n=== END TRANSCRIPT ==="
)
try:
await agent_manager.send_message(
edit_sid, body.prompt, mode=body.mode, model=body.model, prepend_context=context,
)
except Exception:
logger.exception("ask-run: failed to answer with run context for workflow %s", workflow_id)
return {"session_id": edit_sid}
async def p_end_edit_session(wf) -> None:
"""End a workflow's Edit-Agent session (after Save or Discard) so the next
edit opens a brand-new chat against the current workflow instead of
+33 -5
View File
@@ -64,6 +64,7 @@ import ContextDrawer from './shell/ContextDrawer';
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
import { ContextPath } from '@/app/components/editor/DirectoryBrowser';
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';
@@ -238,6 +239,7 @@ interface QueuedMessage {
attachedSkills?: Array<{ id: string; name: string; content: string }>;
selectedBrowserIds?: string[];
selectedAppIds?: string[];
attachedRunId?: string;
}
interface AgentChatProps {
@@ -252,9 +254,20 @@ interface AgentChatProps {
// 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, initialContextPaths, onBranch, workflowEditId }) => {
const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose, embedded, autoFocus, 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 },
@@ -362,6 +375,13 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
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).
const statusRef = useRef<string | undefined>(undefined);
@@ -470,6 +490,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' }));
@@ -906,7 +930,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
) => {
if (!id) return;
scrollToBottom();
const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds };
const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds, attachedRunId: runContextRef.current?.runId };
if (agentBusy) {
messageQueueRef.current.push(msg);
setQueueLength(messageQueueRef.current.length);
@@ -1819,7 +1843,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
/>
</Box>
)}
{showResumeBubble && session.status === 'stopped' && !isWorkflowRunSidecar && (
{showResumeBubble && session.status === 'stopped' && !isWorkflowRunSidecar && !readOnly && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
<Box
onClick={handleResume}
@@ -2255,7 +2279,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
</Fade>
);
})()}
{isStoppableSidecar ? (
{readOnly ? null : isStoppableSidecar ? (
<ForceStopAgentBar onStop={handleStop} onSaveWorkflow={onTestSaveWorkflow} onContinueEditing={onTestContinueEditing} testState={testState} />
) : (
<Box sx={{ position: 'relative' }}>
@@ -2274,6 +2298,10 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
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}
@@ -2306,7 +2334,7 @@ function WorkflowModelNotice({ c, label }: { c: ReturnType<typeof useClaudeToken
}}>
<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 run on <b>{display}</b> after you save.
This workflow will now be using <b>{display}</b>.
</Box>
</Box>
</Fade>
+10 -1
View File
@@ -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);
@@ -349,6 +355,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}
@@ -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}
@@ -1,26 +1,43 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } 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 { WC, colorForId, statusChip } from './uiKit';
import { isRunning } from './model';
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, 'modify');
const sessionId = useEditAgentSession(workflowId);
const [name, setName] = useState(workflow?.title ?? '');
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(() => { setName(workflow?.title ?? ''); }, [workflow?.title]);
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.paper }} />;
@@ -31,7 +48,9 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId
const runNow = () => {
if (running) return;
dispatch(runWorkflowNow({ id: workflow.id, signature: stepsSignature(workflow.steps) }));
dispatch(runWorkflowNow({ id: workflow.id, signature: stepsSignature(workflow.steps) }))
.unwrap().then((res) => { autoCtxRunId.current = res.run_id || null; }).catch(() => {});
dispatch(openWorkflowMonitor({ workflowId: workflow.id }));
};
const commitName = () => {
const t = name.trim();
@@ -41,9 +60,9 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId
return (
<>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: WC.paper }}>
<div style={{ flex: 'none', padding: '20px 28px 16px', borderBottom: '1px solid rgba(33,30,27,0.06)' }}>
<div style={{ flex: 'none', padding: '20px 28px 16px', borderBottom: `1px solid rgba(${WC.inkRGB},0.06)` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 13 }}>
<div style={{ width: 14, height: 14, borderRadius: 4, background: colorForId(workflow.id), boxShadow: '0 0 0 1px rgba(33,30,27,0.14)', flex: 'none' }} />
<ColorSwatch value={colorForWorkflow(workflow)} onChange={(hex) => patch(workflow, { color: hex })} size={14} />
<input
value={name}
onChange={(e) => setName(e.target.value)}
@@ -51,12 +70,12 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
style={{ flex: 1, minWidth: 0, border: 'none', background: 'transparent', fontFamily: "'Newsreader',serif", fontSize: 25, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }}
/>
<span style={statusChip(status)}>{statusText}</span>
<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 now'}</span>
<span>{running ? 'Running…' : 'Run'}</span>
</button>
</div>
{workflow.description && <div style={{ fontSize: 13.5, color: WC.muted, marginTop: 7, paddingLeft: 27 }}>{workflow.description}</div>}
@@ -64,7 +83,14 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
{sessionId
? <AgentChat sessionId={sessionId} embedded workflowEditId={workflow.id} />
? <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>
@@ -23,3 +23,22 @@ export async function ensureEditAgentSession(workflowId: string): Promise<string
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;
}
}
@@ -1,4 +1,5 @@
import type { Workflow, WorkflowRun, ScheduleConfig, ActiveRun } from '@/shared/state/workflowsSlice';
import type { WorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice';
import { isScheduleActive, fireTimesWithin } from '@/app/pages/Workflows/scheduleUtils';
// The design speaks in four cadence buckets; the backend speaks in repeat_unit.
@@ -129,6 +130,31 @@ export function runDuration(run: WorkflowRun): string {
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,
@@ -164,6 +164,16 @@ export interface DashboardLayoutState {
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 = {
@@ -194,6 +204,7 @@ const initialState: DashboardLayoutState = {
workflowsMonitorId: null,
workflowsMonitorRunId: null,
workflowsMonitorCard: null,
workflowsRunContext: null,
};
interface LayoutPayload {
@@ -1063,6 +1074,14 @@ const dashboardLayoutSlice = createSlice({
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;
@@ -1571,6 +1590,8 @@ export const {
openWorkflowMonitor,
closeWorkflowMonitor,
setWorkflowsMonitorPosition,
setWorkflowsRunContext,
clearWorkflowsRunContext,
setWorkflowsHubPosition,
setWorkflowsHubSize,
clearPendingFocusWorkflowsHub,