Skip to content

Commit d9db947

Browse files
cristipufuclaude
andcommitted
feat: streaming eval results, explorer canvas, tool call tracking
- Stream eval item results in real-time via WebSocket instead of waiting for the entire eval set to complete before updating the grid - Add ExplorerCanvas component for visualizing entrypoint graphs in the explorer tab system - Extract ELK layout logic into reusable elkLayout.ts module - Track tool calls by unique ID instead of tool name to handle duplicate tool invocations correctly - Preserve state.db across hot reloads, ignore __uipath/ dir in watcher - Improve agent context compaction with orphaned tool result handling - Add closeable tool detail panel in agent messages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5d3ff59 commit d9db947

27 files changed

Lines changed: 1005 additions & 564 deletions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-dev"
3-
version = "0.0.72"
3+
version = "0.0.73"
44
description = "UiPath Developer Console"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath/dev/server/__init__.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,13 @@ async def reload_factory(self) -> None:
189189
del sys.modules[name]
190190
logger.debug("Flushed %d user modules", len(to_remove))
191191

192-
# Recreate factory
192+
# Recreate factory — preserve state file across reloads so the dev
193+
# server doesn't delete state.db while other processes may use it.
193194
self.runtime_factory = self.factory_creator()
195+
try:
196+
self.runtime_factory.context.keep_state_file = True # type: ignore[attr-defined]
197+
except AttributeError:
198+
pass
194199
self.run_service.runtime_factory = self.runtime_factory
195200
await self.run_service.apply_factory_settings()
196201
self.reload_pending = False
@@ -342,12 +347,12 @@ def _on_agent_event(self, event: Any) -> None:
342347
cm.broadcast_agent_thinking(sid, content)
343348
case PlanUpdated(session_id=sid, items=items):
344349
cm.broadcast_agent_plan(sid, items)
345-
case ToolStarted(session_id=sid, tool=tool, args=args):
346-
cm.broadcast_agent_tool_use(sid, tool, args)
350+
case ToolStarted(session_id=sid, tool_call_id=tcid, tool=tool, args=args):
351+
cm.broadcast_agent_tool_use(sid, tcid, tool, args)
347352
case ToolCompleted(
348-
session_id=sid, tool=tool, result=result, is_error=is_error
353+
session_id=sid, tool_call_id=tcid, tool=tool, result=result, is_error=is_error
349354
):
350-
cm.broadcast_agent_tool_result(sid, tool, result, is_error)
355+
cm.broadcast_agent_tool_result(sid, tcid, tool, result, is_error)
351356
case ToolApprovalRequired(
352357
session_id=sid, tool_call_id=tcid, tool=tool, args=args
353358
):

src/uipath/dev/server/frontend/src/App.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import EvaluatorsView from "./components/evaluators/EvaluatorDetail";
2727
import CreateEvaluatorView from "./components/evaluators/CreateEvaluatorView";
2828
import ExplorerSidebar from "./components/explorer/ExplorerSidebar";
2929
import FileEditor from "./components/explorer/FileEditor";
30-
import StateDbViewer from "./components/explorer/StateDbViewer";
3130
import AgentChatSidebar from "./components/agent/AgentChatSidebar";
3231
import { useExplorerStore } from "./store/useExplorerStore";
3332

@@ -68,7 +67,6 @@ export default function App() {
6867
evaluatorId,
6968
evaluatorFilter,
7069
explorerFile,
71-
stateDbTable,
7270
navigate,
7371
} = useHashRoute();
7472

@@ -344,7 +342,6 @@ export default function App() {
344342
// --- Render main content based on section ---
345343
const renderMainContent = () => {
346344
if (section === "explorer") {
347-
if (stateDbTable !== null) return <StateDbViewer table={stateDbTable} />;
348345
if (explorerTabs.length > 0 || explorerFile) return <FileEditor />;
349346
return (
350347
<div className="flex items-center justify-center h-full text-[var(--text-muted)]">

src/uipath/dev/server/frontend/src/components/agent/AgentMessage.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ function ToolChip({ tc, active, onClick }: { tc: AgentToolCall; active: boolean;
206206
);
207207
}
208208

209-
function ToolDetailPanel({ tc }: { tc: AgentToolCall }) {
209+
function ToolDetailPanel({ tc, onClose }: { tc: AgentToolCall; onClose: () => void }) {
210210
const hasResult = tc.result !== undefined;
211211
const hasArgs = tc.args != null && Object.keys(tc.args).length > 0;
212212
const diff = isEditFileDiff(tc);
@@ -232,6 +232,18 @@ function ToolDetailPanel({ tc }: { tc: AgentToolCall }) {
232232
Error
233233
</span>
234234
)}
235+
<button
236+
onClick={onClose}
237+
className="ml-auto shrink-0 flex items-center justify-center w-5 h-5 rounded cursor-pointer"
238+
style={{ background: "none", border: "none", color: "var(--text-muted)" }}
239+
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--text-primary)"; }}
240+
onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-muted)"; }}
241+
>
242+
<svg width="10" height="10" viewBox="0 0 10 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
243+
<line x1="2" y1="2" x2="8" y2="8" />
244+
<line x1="8" y1="2" x2="2" y2="8" />
245+
</svg>
246+
</button>
235247
</div>
236248

237249
{/* Args + Result stacked */}
@@ -328,7 +340,7 @@ function ToolCard({ message }: Props) {
328340
</div>
329341
{/* Detail panel below chips */}
330342
{expandedIdx !== null && calls[expandedIdx] && (
331-
<ToolDetailPanel tc={calls[expandedIdx]} />
343+
<ToolDetailPanel tc={calls[expandedIdx]} onClose={() => setExpandedIdx(null)} />
332344
)}
333345
</div>
334346
</div>

src/uipath/dev/server/frontend/src/components/evals/EvalRunResults.tsx

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ interface Props {
1212
itemName?: string | null;
1313
}
1414

15-
function formatScore(score: number | null): string {
16-
if (score === null) return "-";
15+
function formatScore(score: number | null | undefined): string {
16+
if (score == null) return "-";
1717
return `${Math.round(score * 100)}%`;
1818
}
1919

20-
function scoreColor(score: number | null): string {
21-
if (score === null) return "var(--text-muted)";
20+
function scoreColor(score: number | null | undefined): string {
21+
if (score == null) return "var(--text-muted)";
2222
const pct = score * 100;
2323
if (pct >= 80) return "var(--success)";
2424
if (pct >= 50) return "var(--warning)";
@@ -71,6 +71,7 @@ export default function EvalRunResults({ evalRunId, itemName }: Props) {
7171

7272
const storeRun = useEvalStore((s) => s.evalRuns[evalRunId]);
7373
const evaluators = useEvalStore((s) => s.evaluators);
74+
const streamingItems = useEvalStore((s) => s.streamingResults[evalRunId]);
7475

7576
useEffect(() => {
7677
setLoading(true);
@@ -97,9 +98,12 @@ export default function EvalRunResults({ evalRunId, itemName }: Props) {
9798
// Auto-select first completed item as results come in (when no item is in route)
9899
useEffect(() => {
99100
if (itemName || !detail?.results) return;
100-
const first = detail.results.find((r) => r.status === "completed") ?? detail.results[0];
101+
const results = streamingItems
102+
? detail.results.map((r) => streamingItems[r.name] ?? r)
103+
: detail.results;
104+
const first = results.find((r) => r.status === "completed") ?? results[0];
101105
if (first) navigate(`#/evals/runs/${evalRunId}/${encodeURIComponent(first.name)}`);
102-
}, [detail?.results]);
106+
}, [detail?.results, streamingItems]);
103107

104108
// --- Row resize (item list height) ---
105109
const onRowResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
@@ -191,8 +195,21 @@ export default function EvalRunResults({ evalRunId, itemName }: Props) {
191195
const run = storeRun ?? detail;
192196
const status = statusStyles[run.status] ?? statusStyles.pending;
193197
const isRunning = run.status === "running";
194-
const evaluatorIds = Object.keys(run.evaluator_scores ?? {});
195-
const selectedItem = detail.results.find((r) => r.name === selectedItemName) ?? null;
198+
// Derive evaluator IDs from run scores (set on completion) or from first streamed item
199+
const evaluatorIds = Object.keys(run.evaluator_scores ?? {}).length > 0
200+
? Object.keys(run.evaluator_scores)
201+
: Object.keys(
202+
streamingItems
203+
? Object.values(streamingItems).find((r) => Object.keys(r.scores).length > 0)?.scores ?? {}
204+
: {},
205+
);
206+
207+
// Merge streaming item results over the fetched detail results
208+
const mergedResults = streamingItems
209+
? detail.results.map((r) => streamingItems[r.name] ?? r)
210+
: detail.results;
211+
212+
const selectedItem = mergedResults.find((r) => r.name === selectedItemName) ?? null;
196213
const selectedTraces = (selectedItem?.traces ?? []).map((t) => ({ ...t, run_id: "" }));
197214

198215
return (
@@ -279,7 +296,7 @@ export default function EvalRunResults({ evalRunId, itemName }: Props) {
279296
</div>
280297
{/* Scrollable item rows */}
281298
<div className="flex-1 overflow-y-auto">
282-
{detail.results.map((item: EvalItemResult) => {
299+
{mergedResults.map((item: EvalItemResult) => {
283300
const isPending = item.status === "pending";
284301
const isFailed = item.status === "failed";
285302
const isSelected = item.name === selectedItemName;
@@ -337,7 +354,7 @@ export default function EvalRunResults({ evalRunId, itemName }: Props) {
337354
</button>
338355
);
339356
})}
340-
{detail.results.length === 0 && (
357+
{mergedResults.length === 0 && (
341358
<div className="flex items-center justify-center py-8 text-[var(--text-muted)] text-xs">
342359
{isRunning ? "Waiting for results..." : "No results"}
343360
</div>
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { useEffect, useRef, useState } from "react";
2+
import ReactFlow, {
3+
Background,
4+
Controls,
5+
MiniMap,
6+
useNodesState,
7+
useEdgesState,
8+
type ReactFlowInstance,
9+
} from "reactflow";
10+
import "reactflow/dist/style.css";
11+
import { getEntrypointGraph } from "../../api/client";
12+
import { useRunStore } from "../../store/useRunStore";
13+
import { runElkLayout } from "../graph/elkLayout";
14+
import StartNode from "../graph/nodes/StartNode";
15+
import EndNode from "../graph/nodes/EndNode";
16+
import ModelNode from "../graph/nodes/ModelNode";
17+
import ToolNode from "../graph/nodes/ToolNode";
18+
import GroupNode from "../graph/nodes/GroupNode";
19+
import DefaultNode from "../graph/nodes/DefaultNode";
20+
import ElkEdge from "../graph/edges/ElkEdge";
21+
22+
const nodeTypes = {
23+
startNode: StartNode,
24+
endNode: EndNode,
25+
modelNode: ModelNode,
26+
toolNode: ToolNode,
27+
groupNode: GroupNode,
28+
defaultNode: DefaultNode,
29+
};
30+
31+
const edgeTypes = { elk: ElkEdge };
32+
33+
export default function ExplorerCanvas() {
34+
const entrypoints = useRunStore((s) => s.entrypoints);
35+
const [selectedEp, setSelectedEp] = useState<string | null>(null);
36+
const [nodes, setNodes, onNodesChange] = useNodesState([]);
37+
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
38+
const [loading, setLoading] = useState(true);
39+
const [graphUnavailable, setGraphUnavailable] = useState(false);
40+
const layoutRef = useRef(0);
41+
const lastGraphHash = useRef<string>("");
42+
const rfInstance = useRef<ReactFlowInstance | null>(null);
43+
const containerRef = useRef<HTMLDivElement>(null);
44+
45+
// Auto-select first entrypoint
46+
useEffect(() => {
47+
if (entrypoints.length > 0 && (!selectedEp || !entrypoints.includes(selectedEp))) {
48+
setSelectedEp(entrypoints[0]);
49+
}
50+
}, [entrypoints, selectedEp]);
51+
52+
// Fetch graph and run ELK layout when entrypoint changes or entrypoints refresh
53+
useEffect(() => {
54+
if (!selectedEp) return;
55+
56+
const layoutId = ++layoutRef.current;
57+
// Only show loading spinner on first load (no graph rendered yet)
58+
const isFirstLoad = !lastGraphHash.current;
59+
if (isFirstLoad) setLoading(true);
60+
setGraphUnavailable(false);
61+
62+
getEntrypointGraph(selectedEp)
63+
.then(async (graphData) => {
64+
if (layoutRef.current !== layoutId) return;
65+
if (!graphData.nodes.length) {
66+
setGraphUnavailable(true);
67+
return;
68+
}
69+
// Skip re-layout if the graph structure hasn't changed
70+
const hash = JSON.stringify(graphData);
71+
if (hash === lastGraphHash.current) {
72+
return;
73+
}
74+
lastGraphHash.current = hash;
75+
const { nodes: laidNodes, edges: laidEdges } =
76+
await runElkLayout(graphData);
77+
if (layoutRef.current !== layoutId) return;
78+
setNodes(laidNodes);
79+
setEdges(laidEdges);
80+
setTimeout(() => {
81+
rfInstance.current?.fitView({ padding: 0.1, duration: 200 });
82+
}, 100);
83+
})
84+
.catch(() => {
85+
if (layoutRef.current === layoutId) setGraphUnavailable(true);
86+
})
87+
.finally(() => {
88+
if (layoutRef.current === layoutId) setLoading(false);
89+
});
90+
}, [selectedEp, entrypoints, setNodes, setEdges]);
91+
92+
// Fit view on container resize
93+
useEffect(() => {
94+
const el = containerRef.current;
95+
if (!el) return;
96+
const ro = new ResizeObserver(() => {
97+
rfInstance.current?.fitView({ padding: 0.1, duration: 200 });
98+
});
99+
ro.observe(el);
100+
return () => ro.disconnect();
101+
}, [loading, graphUnavailable]);
102+
103+
if (loading) {
104+
return (
105+
<div
106+
className="flex items-center justify-center h-full"
107+
style={{ color: "var(--text-muted)" }}
108+
>
109+
Loading graph...
110+
</div>
111+
);
112+
}
113+
114+
if (graphUnavailable) {
115+
return (
116+
<div
117+
className="flex flex-col items-center justify-center h-full gap-4"
118+
style={{ color: "var(--text-muted)" }}
119+
>
120+
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
121+
<rect x="38" y="10" width="44" height="24" rx="6" stroke="currentColor" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.4" />
122+
<line x1="60" y1="34" x2="60" y2="46" stroke="currentColor" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.3" />
123+
<rect x="12" y="46" width="44" height="24" rx="6" stroke="currentColor" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.3" />
124+
<rect x="64" y="46" width="44" height="24" rx="6" stroke="currentColor" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.3" />
125+
<line x1="60" y1="46" x2="34" y2="46" stroke="currentColor" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.3" />
126+
<line x1="60" y1="46" x2="86" y2="46" stroke="currentColor" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.3" />
127+
<line x1="34" y1="70" x2="34" y2="82" stroke="currentColor" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.3" />
128+
<line x1="86" y1="70" x2="86" y2="82" stroke="currentColor" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.3" />
129+
<line x1="34" y1="82" x2="60" y2="82" stroke="currentColor" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.3" />
130+
<line x1="86" y1="82" x2="60" y2="82" stroke="currentColor" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.3" />
131+
<line x1="60" y1="82" x2="60" y2="86" stroke="currentColor" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.3" />
132+
<rect x="38" y="86" width="44" height="24" rx="6" stroke="currentColor" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.4" />
133+
</svg>
134+
<span className="text-xs">No graph schema available</span>
135+
</div>
136+
);
137+
}
138+
139+
return (
140+
<div ref={containerRef} className="h-full explorer-canvas">
141+
<style>{`
142+
.explorer-canvas .react-flow__handle {
143+
opacity: 0 !important;
144+
width: 0 !important;
145+
height: 0 !important;
146+
min-width: 0 !important;
147+
min-height: 0 !important;
148+
border: none !important;
149+
pointer-events: none !important;
150+
}
151+
.explorer-canvas .react-flow__edges {
152+
overflow: visible !important;
153+
z-index: 1 !important;
154+
}
155+
`}</style>
156+
{entrypoints.length > 1 && (
157+
<div style={{
158+
position: "absolute",
159+
top: 8,
160+
left: 8,
161+
zIndex: 10,
162+
}}>
163+
<select
164+
value={selectedEp ?? ""}
165+
onChange={(e) => setSelectedEp(e.target.value)}
166+
style={{
167+
background: "var(--bg-secondary)",
168+
color: "var(--text-primary)",
169+
border: "1px solid var(--node-border)",
170+
borderRadius: 6,
171+
padding: "4px 8px",
172+
fontSize: 12,
173+
}}
174+
>
175+
{entrypoints.map((ep) => (
176+
<option key={ep} value={ep}>{ep}</option>
177+
))}
178+
</select>
179+
</div>
180+
)}
181+
<ReactFlow
182+
nodes={nodes}
183+
edges={edges}
184+
onNodesChange={onNodesChange}
185+
onEdgesChange={onEdgesChange}
186+
nodeTypes={nodeTypes}
187+
edgeTypes={edgeTypes}
188+
onInit={(instance) => { rfInstance.current = instance; }}
189+
fitView
190+
proOptions={{ hideAttribution: true }}
191+
nodesDraggable={false}
192+
nodesConnectable={false}
193+
elementsSelectable={false}
194+
>
195+
<Background color="var(--bg-tertiary)" gap={16} />
196+
<Controls showInteractive={false} />
197+
<MiniMap
198+
nodeColor={(n) => {
199+
if (n.type === "groupNode") return "var(--bg-tertiary)";
200+
return "var(--node-border)";
201+
}}
202+
nodeStrokeWidth={0}
203+
style={{ background: "var(--bg-secondary)", width: 120, height: 80 }}
204+
/>
205+
</ReactFlow>
206+
</div>
207+
);
208+
}

0 commit comments

Comments
 (0)