Skip to content

Commit df112e0

Browse files
ersinkocclaude
andcommitted
✨ feat(ui): LLM conversation editor + execution progress bar + timeline labels
LLM config panel: - Conversation Context editor: add/edit/remove multi-turn messages with role selector - Response Format selector (from previous commit, now with conversation context) Workflow execution monitoring: - Progress bar in editor toolbar: shows running node name, completed/total, retries - Execution timeline: shows actual node labels instead of raw IDs (node_3 → "Analyze Data") - Progress tracking state: total, completed, running, failed, retries counters - Log viewer: loads workflow data to resolve node labels in historical logs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f785710 commit df112e0

6 files changed

Lines changed: 220 additions & 21 deletions

File tree

packages/ui/src/components/workflows/panels/LlmConfigPanel.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,77 @@ export function LlmConfigPanel({
527527
</p>
528528
</div>
529529

530+
{/* Conversation Context */}
531+
<div>
532+
<div className="flex items-center justify-between mb-1">
533+
<label className="block text-xs font-medium text-text-muted dark:text-dark-text-muted">
534+
Conversation Context
535+
</label>
536+
<button
537+
type="button"
538+
onClick={() => {
539+
const msgs: LlmNodeData['conversationMessages'] = [
540+
...(data.conversationMessages ?? []),
541+
];
542+
msgs!.push({ role: 'user', content: '' });
543+
pushUpdate({ conversationMessages: msgs });
544+
}}
545+
className="text-[10px] text-primary hover:text-primary/80 transition-colors"
546+
>
547+
+ Add Message
548+
</button>
549+
</div>
550+
<p className="text-[10px] text-text-muted mb-2">
551+
Optional multi-turn context messages inserted before the main User Message
552+
</p>
553+
{(data.conversationMessages ?? []).map((msg, i) => (
554+
<div key={i} className="flex gap-1.5 mb-2">
555+
<select
556+
value={msg.role}
557+
onChange={(e) => {
558+
const msgs: LlmNodeData['conversationMessages'] = [
559+
...(data.conversationMessages ?? []),
560+
];
561+
msgs![i] = {
562+
role: e.target.value as 'user' | 'assistant',
563+
content: msgs![i]!.content,
564+
};
565+
pushUpdate({ conversationMessages: msgs });
566+
}}
567+
className="px-1.5 py-1 text-[10px] bg-bg-primary dark:bg-dark-bg-primary border border-border dark:border-dark-border rounded text-text-primary dark:text-dark-text-primary w-20 shrink-0"
568+
>
569+
<option value="user">User</option>
570+
<option value="assistant">Assistant</option>
571+
</select>
572+
<input
573+
type="text"
574+
value={msg.content}
575+
onChange={(e) => {
576+
const msgs: LlmNodeData['conversationMessages'] = [
577+
...(data.conversationMessages ?? []),
578+
];
579+
msgs![i] = { role: msgs![i]!.role, content: e.target.value };
580+
pushUpdate({ conversationMessages: msgs });
581+
}}
582+
placeholder="Message content..."
583+
className={`${INPUT_CLS} flex-1`}
584+
/>
585+
<button
586+
type="button"
587+
onClick={() => {
588+
const msgs = (data.conversationMessages ?? []).filter((_, j) => j !== i);
589+
pushUpdate({
590+
conversationMessages: msgs.length > 0 ? msgs : undefined,
591+
});
592+
}}
593+
className="p-1 text-text-muted hover:text-error transition-colors shrink-0"
594+
>
595+
<X className="w-3 h-3" />
596+
</button>
597+
</div>
598+
))}
599+
</div>
600+
530601
{/* Advanced: custom API key + base URL */}
531602
<div>
532603
<button

packages/ui/src/pages/WorkflowEditorPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ function WorkflowEditorInner() {
9090
handleArrange={editor.handleArrange}
9191
handleExecute={editor.handleExecute}
9292
handleCancel={editor.handleCancel}
93+
executionProgress={editor.executionProgress}
9394
/>
9495

9596
{/* Three-panel layout */}

packages/ui/src/pages/WorkflowLogViewerPage.tsx

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -118,16 +118,40 @@ export function WorkflowLogViewerPage() {
118118
[sortedResults]
119119
);
120120

121-
// Build minimal node list for ExecutionTimeline
122-
const timelineNodes = useMemo(
123-
() =>
124-
sortedResults.map((r) => ({
121+
// Load workflow to get actual node labels for the timeline
122+
const [workflowNodes, setWorkflowNodes] = useState<
123+
Array<{ id: string; type: string; data: Record<string, unknown> }>
124+
>([]);
125+
126+
useEffect(() => {
127+
if (!log?.workflowId) return;
128+
workflowsApi
129+
.get(log.workflowId)
130+
.then((wf) =>
131+
setWorkflowNodes(
132+
wf.nodes.map((n) => ({
133+
id: n.id,
134+
type: n.type,
135+
data: n.data as unknown as Record<string, unknown>,
136+
}))
137+
)
138+
)
139+
.catch(() => {
140+
/* workflow may have been deleted */
141+
});
142+
}, [log?.workflowId]);
143+
144+
// Build node list for ExecutionTimeline, using workflow labels when available
145+
const timelineNodes = useMemo(() => {
146+
return sortedResults.map((r) => {
147+
const wfNode = workflowNodes.find((n) => n.id === r.nodeId);
148+
return {
125149
id: r.nodeId,
126-
type: 'tool',
127-
data: { label: r.nodeId } as Record<string, unknown>,
128-
})),
129-
[sortedResults]
130-
);
150+
type: wfNode?.type ?? 'tool',
151+
data: { label: (wfNode?.data?.label as string) ?? r.nodeId } as Record<string, unknown>,
152+
};
153+
});
154+
}, [sortedResults, workflowNodes]);
131155

132156
const handleReplay = useCallback(async () => {
133157
if (!logId || isReplaying) return;
@@ -300,14 +324,19 @@ export function WorkflowLogViewerPage() {
300324
<ExecutionTimeline nodeResults={log.nodeResults} nodes={timelineNodes} />
301325
) : (
302326
<div className="space-y-2">
303-
{filteredResults.map((result) => (
304-
<NodeResultCard
305-
key={result.nodeId}
306-
result={result}
307-
expanded={expandedNodes.has(result.nodeId)}
308-
onToggle={() => toggleNode(result.nodeId)}
309-
/>
310-
))}
327+
{filteredResults.map((result) => {
328+
const wfNode = workflowNodes.find((n) => n.id === result.nodeId);
329+
const nodeLabel = (wfNode?.data?.label as string) ?? result.nodeId;
330+
return (
331+
<NodeResultCard
332+
key={result.nodeId}
333+
result={result}
334+
label={nodeLabel}
335+
expanded={expandedNodes.has(result.nodeId)}
336+
onToggle={() => toggleNode(result.nodeId)}
337+
/>
338+
);
339+
})}
311340

312341
{filteredResults.length === 0 && (
313342
<p className="text-center text-sm text-text-muted dark:text-dark-text-muted py-8">
@@ -327,10 +356,12 @@ export function WorkflowLogViewerPage() {
327356

328357
function NodeResultCard({
329358
result,
359+
label,
330360
expanded,
331361
onToggle,
332362
}: {
333363
result: NodeResult & { nodeId: string };
364+
label: string;
334365
expanded: boolean;
335366
onToggle: () => void;
336367
}) {
@@ -350,7 +381,7 @@ function NodeResultCard({
350381
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
351382
)}
352383
<StatusIcon className="w-4 h-4 shrink-0" />
353-
<span className="text-sm font-medium flex-1 truncate">{result.nodeId}</span>
384+
<span className="text-sm font-medium flex-1 truncate">{label}</span>
354385
{result.retryAttempts != null && result.retryAttempts > 0 && (
355386
<span className="px-1.5 py-0.5 text-[10px] bg-warning/10 text-warning rounded-full">
356387
{result.retryAttempts} {result.retryAttempts === 1 ? 'retry' : 'retries'}

packages/ui/src/pages/workflows/WorkflowEditorToolbar.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ export interface WorkflowEditorToolbarProps {
5252
handleArrange: () => void;
5353
handleExecute: (dryRun: boolean) => void;
5454
handleCancel: () => void;
55+
executionProgress: {
56+
total: number;
57+
completed: number;
58+
running: string | null;
59+
failed: number;
60+
retries: number;
61+
} | null;
5562
}
5663

5764
export function WorkflowEditorToolbar({
@@ -81,8 +88,10 @@ export function WorkflowEditorToolbar({
8188
handleArrange,
8289
handleExecute,
8390
handleCancel,
91+
executionProgress,
8492
}: WorkflowEditorToolbarProps) {
8593
return (
94+
<div>
8695
<header className="flex items-center gap-3 px-4 py-2.5 border-b border-border dark:border-dark-border bg-bg-secondary dark:bg-dark-bg-secondary">
8796
<button
8897
onClick={() => navigate('/workflows')}
@@ -263,5 +272,30 @@ export function WorkflowEditorToolbar({
263272
</>
264273
)}
265274
</header>
275+
276+
{isExecuting && executionProgress && (
277+
<div className="flex items-center gap-3 px-4 py-2 bg-warning/10 border-b border-warning/20">
278+
<div className="flex-1">
279+
<div className="flex items-center justify-between text-xs mb-1">
280+
<span className="font-medium text-warning">
281+
{executionProgress.running
282+
? `Running: ${executionProgress.running}`
283+
: 'Processing...'}
284+
</span>
285+
<span className="text-text-muted">
286+
{executionProgress.completed}/{executionProgress.total} nodes
287+
{executionProgress.retries > 0 ? ` (${executionProgress.retries} retries)` : ''}
288+
</span>
289+
</div>
290+
<div className="w-full h-1.5 bg-warning/20 rounded-full overflow-hidden">
291+
<div
292+
className="h-full bg-warning rounded-full transition-all duration-300"
293+
style={{ width: `${(executionProgress.completed / Math.max(executionProgress.total, 1)) * 100}%` }}
294+
/>
295+
</div>
296+
</div>
297+
</div>
298+
)}
299+
</div>
266300
);
267301
}

packages/ui/src/pages/workflows/useWorkflowEditor.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export function useWorkflowEditor() {
118118
toast,
119119
});
120120

121-
const { handleSave, handleExecute, handleCancel } = useWorkflowExecution({
121+
const { handleSave, handleExecute, handleCancel, executionProgress } = useWorkflowExecution({
122122
id,
123123
workflow,
124124
nodes,
@@ -458,6 +458,9 @@ export function useWorkflowEditor() {
458458
onDragOver,
459459
onDrop,
460460

461+
// Execution progress
462+
executionProgress,
463+
461464
// Actions
462465
handleSave,
463466
handleExecute,

0 commit comments

Comments
 (0)