mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
[aidan] feat/run-context: attach a run as removable chat context
This commit is contained in:
@@ -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"):
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user