Skip to content

Commit 83b89c4

Browse files
authored
Merge pull request #53 from UiPath/fix/ws-multi-tab-subscription
Frontend UX improvements and WebSocket fix
2 parents da6f305 + fbc1686 commit 83b89c4

16 files changed

Lines changed: 456 additions & 160 deletions

File tree

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.33"
3+
version = "0.0.34"
44
description = "UiPath Developer Console"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath/dev/server/frontend/src/api/websocket.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export class WsClient {
88
private handlers: Set<MessageHandler> = new Set();
99
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
1010
private shouldReconnect = true;
11+
private pendingMessages: string[] = [];
12+
private activeSubscriptions: Set<string> = new Set();
1113

1214
constructor(url?: string) {
1315
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
@@ -21,6 +23,15 @@ export class WsClient {
2123

2224
this.ws.onopen = () => {
2325
console.log("[ws] connected");
26+
// Re-subscribe to active subscriptions after reconnect
27+
for (const runId of this.activeSubscriptions) {
28+
this.sendRaw(JSON.stringify({ type: "subscribe", payload: { run_id: runId } }));
29+
}
30+
// Flush any messages queued while connecting
31+
for (const msg of this.pendingMessages) {
32+
this.sendRaw(msg);
33+
}
34+
this.pendingMessages = [];
2435
};
2536

2637
this.ws.onmessage = (event) => {
@@ -56,17 +67,28 @@ export class WsClient {
5667
return () => this.handlers.delete(handler);
5768
}
5869

70+
private sendRaw(data: string): void {
71+
if (this.ws?.readyState === WebSocket.OPEN) {
72+
this.ws.send(data);
73+
}
74+
}
75+
5976
send(type: ClientCommandType, payload: Record<string, unknown>): void {
77+
const data = JSON.stringify({ type, payload });
6078
if (this.ws?.readyState === WebSocket.OPEN) {
61-
this.ws.send(JSON.stringify({ type, payload }));
79+
this.ws.send(data);
80+
} else {
81+
this.pendingMessages.push(data);
6282
}
6383
}
6484

6585
subscribe(runId: string): void {
86+
this.activeSubscriptions.add(runId);
6687
this.send("subscribe", { run_id: runId });
6788
}
6889

6990
unsubscribe(runId: string): void {
91+
this.activeSubscriptions.delete(runId);
7092
this.send("unsubscribe", { run_id: runId });
7193
}
7294

src/uipath/dev/server/frontend/src/components/chat/ChatMessage.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ interface ChatMsg {
99

1010
interface Props {
1111
message: ChatMsg;
12+
onToolCallClick?: (name: string, occurrenceIndex: number) => void;
13+
toolCallIndices?: number[];
1214
}
1315

1416
const ROLE_CONFIG: Record<string, { label: string; color: string }> = {
@@ -17,7 +19,7 @@ const ROLE_CONFIG: Record<string, { label: string; color: string }> = {
1719
assistant: { label: "AI", color: "var(--success)" },
1820
};
1921

20-
export default function ChatMessage({ message }: Props) {
22+
export default function ChatMessage({ message, onToolCallClick, toolCallIndices }: Props) {
2123
const isUser = message.role === "user";
2224
const hasTool = message.tool_calls && message.tool_calls.length > 0;
2325
const roleKey = isUser ? "user" : hasTool ? "tool" : "assistant";
@@ -61,15 +63,16 @@ export default function ChatMessage({ message }: Props) {
6163
{/* Tool calls */}
6264
{message.tool_calls && message.tool_calls.length > 0 && (
6365
<div className="flex flex-wrap gap-1 mt-1 pl-2.5">
64-
{message.tool_calls.map((tc) => (
66+
{message.tool_calls.map((tc, i) => (
6567
<span
66-
key={tc.name}
67-
className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded"
68+
key={`${tc.name}-${i}`}
69+
className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded cursor-pointer hover:brightness-125"
6870
style={{
6971
background: "var(--bg-primary)",
7072
border: "1px solid var(--border)",
7173
color: tc.has_result ? "var(--success)" : "var(--text-muted)",
7274
}}
75+
onClick={() => onToolCallClick?.(tc.name, toolCallIndices?.[i] ?? 0)}
7376
>
7477
{tc.has_result ? "\u2713" : "\u2022"} {tc.name}
7578
</span>

src/uipath/dev/server/frontend/src/components/chat/ChatPanel.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef } from "react";
1+
import { useEffect, useMemo, useRef } from "react";
22
import type { WsClient } from "../../api/websocket";
33
import { useRunStore } from "../../store/useRunStore";
44
import ChatMessage from "./ChatMessage";
@@ -22,6 +22,25 @@ export default function ChatPanel({ messages, runId, runStatus, ws }: Props) {
2222
const scrollRef = useRef<HTMLDivElement>(null);
2323
const stickToBottom = useRef(true);
2424
const addLocalChatMessage = useRunStore((s) => s.addLocalChatMessage);
25+
const setFocusedSpan = useRunStore((s) => s.setFocusedSpan);
26+
27+
// Precompute per-tool-call occurrence indices across all messages
28+
const toolCallIndicesMap = useMemo(() => {
29+
const map = new Map<string, number[]>();
30+
const counts = new Map<string, number>();
31+
for (const msg of messages) {
32+
if (msg.tool_calls) {
33+
const indices: number[] = [];
34+
for (const tc of msg.tool_calls) {
35+
const count = counts.get(tc.name) ?? 0;
36+
indices.push(count);
37+
counts.set(tc.name, count + 1);
38+
}
39+
map.set(msg.message_id, indices);
40+
}
41+
}
42+
return map;
43+
}, [messages]);
2544

2645
// Track whether user has scrolled away from bottom
2746
const handleScroll = () => {
@@ -64,7 +83,12 @@ export default function ChatPanel({ messages, runId, runStatus, ws }: Props) {
6483
</p>
6584
)}
6685
{messages.map((msg) => (
67-
<ChatMessage key={msg.message_id} message={msg} />
86+
<ChatMessage
87+
key={msg.message_id}
88+
message={msg}
89+
toolCallIndices={toolCallIndicesMap.get(msg.message_id)}
90+
onToolCallClick={(name, idx) => setFocusedSpan({ name, index: idx })}
91+
/>
6892
))}
6993
</div>
7094
<ChatInput

src/uipath/dev/server/frontend/src/components/runs/RunDetailsPanel.tsx

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import GraphPanel from "../graph/GraphPanel";
66
import TraceTree from "../traces/TraceTree";
77
import LogPanel from "../logs/LogPanel";
88
import ChatPanel from "../chat/ChatPanel";
9+
import JsonHighlight from "../shared/JsonHighlight";
910

1011
type Tab = "traces" | "output";
1112

@@ -135,7 +136,7 @@ export default function RunDetailsPanel({ run, ws, activeTab, onTabChange }: Pro
135136
return (
136137
<div className="flex flex-col h-full">
137138
{/* Tab bar */}
138-
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-[var(--border)] bg-[var(--bg-primary)]">
139+
<div className="flex items-center gap-1 px-2 py-2.5 border-b border-[var(--border)] bg-[var(--bg-primary)]">
139140
{tabs.map((tab) => (
140141
<button
141142
key={tab.id}
@@ -329,12 +330,11 @@ function IOView({ run }: { run: RunSummary }) {
329330
<div className="p-4 overflow-y-auto h-full space-y-4">
330331
{/* Input */}
331332
<DataSection title="Input" color="var(--success)" copyText={JSON.stringify(run.input_data, null, 2)}>
332-
<pre
333+
<JsonHighlight
334+
json={JSON.stringify(run.input_data, null, 2)}
333335
className="p-3 rounded-lg text-xs font-mono whitespace-pre-wrap break-words"
334-
style={{ background: "var(--bg-secondary)", color: "var(--text-primary)", border: "1px solid var(--border)" }}
335-
>
336-
{JSON.stringify(run.input_data, null, 2)}
337-
</pre>
336+
style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}
337+
/>
338338
</DataSection>
339339

340340
{/* Output */}
@@ -344,14 +344,13 @@ function IOView({ run }: { run: RunSummary }) {
344344
color="var(--accent)"
345345
copyText={typeof run.output_data === "string" ? run.output_data : JSON.stringify(run.output_data, null, 2)}
346346
>
347-
<pre
348-
className="p-3 rounded-lg text-xs font-mono whitespace-pre-wrap break-words"
349-
style={{ background: "var(--bg-secondary)", color: "var(--text-primary)", border: "1px solid var(--border)" }}
350-
>
351-
{typeof run.output_data === "string"
347+
<JsonHighlight
348+
json={typeof run.output_data === "string"
352349
? run.output_data
353350
: JSON.stringify(run.output_data, null, 2)}
354-
</pre>
351+
className="p-3 rounded-lg text-xs font-mono whitespace-pre-wrap break-words"
352+
style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}
353+
/>
355354
</DataSection>
356355
)}
357356

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { useMemo } from "react";
2+
3+
interface Token {
4+
type: "key" | "string" | "number" | "boolean" | "null" | "punctuation";
5+
text: string;
6+
}
7+
8+
const TOKEN_COLORS: Record<Token["type"], string> = {
9+
key: "var(--info)",
10+
string: "var(--success)",
11+
number: "var(--warning)",
12+
boolean: "var(--accent)",
13+
null: "var(--accent)",
14+
punctuation: "var(--text-muted)",
15+
};
16+
17+
// Tokenize a pre-formatted JSON string into colored spans
18+
function tokenize(json: string): Token[] {
19+
const tokens: Token[] = [];
20+
// Match JSON tokens: strings, numbers, booleans, null, punctuation
21+
const re = /("(?:[^"\\]|\\.)*")\s*:|("(?:[^"\\]|\\.)*")|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b|(true|false)\b|(null)\b|([{}[\]:,])/g;
22+
let lastIndex = 0;
23+
let m: RegExpExecArray | null;
24+
25+
while ((m = re.exec(json)) !== null) {
26+
// Whitespace between tokens
27+
if (m.index > lastIndex) {
28+
tokens.push({ type: "punctuation", text: json.slice(lastIndex, m.index) });
29+
}
30+
31+
if (m[1] !== undefined) {
32+
// Key (string followed by colon)
33+
tokens.push({ type: "key", text: m[1] });
34+
// Find the colon after the key
35+
const colonIdx = json.indexOf(":", m.index + m[1].length);
36+
if (colonIdx !== -1) {
37+
// Add any whitespace between key and colon
38+
if (colonIdx > m.index + m[1].length) {
39+
tokens.push({ type: "punctuation", text: json.slice(m.index + m[1].length, colonIdx) });
40+
}
41+
tokens.push({ type: "punctuation", text: ":" });
42+
re.lastIndex = colonIdx + 1;
43+
}
44+
} else if (m[2] !== undefined) {
45+
tokens.push({ type: "string", text: m[2] });
46+
} else if (m[3] !== undefined) {
47+
tokens.push({ type: "number", text: m[3] });
48+
} else if (m[4] !== undefined) {
49+
tokens.push({ type: "boolean", text: m[4] });
50+
} else if (m[5] !== undefined) {
51+
tokens.push({ type: "null", text: m[5] });
52+
} else if (m[6] !== undefined) {
53+
tokens.push({ type: "punctuation", text: m[6] });
54+
}
55+
56+
lastIndex = re.lastIndex;
57+
}
58+
59+
// Trailing whitespace
60+
if (lastIndex < json.length) {
61+
tokens.push({ type: "punctuation", text: json.slice(lastIndex) });
62+
}
63+
64+
return tokens;
65+
}
66+
67+
interface Props {
68+
json: string;
69+
className?: string;
70+
style?: React.CSSProperties;
71+
}
72+
73+
export default function JsonHighlight({ json, className, style }: Props) {
74+
const tokens = useMemo(() => tokenize(json), [json]);
75+
76+
return (
77+
<pre className={className} style={style}>
78+
{tokens.map((t, i) => (
79+
<span key={i} style={{ color: TOKEN_COLORS[t.type] }}>
80+
{t.text}
81+
</span>
82+
))}
83+
</pre>
84+
);
85+
}

src/uipath/dev/server/frontend/src/components/traces/SpanDetails.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState, useMemo, useCallback } from "react";
22
import type { TraceSpan } from "../../types/run";
3+
import JsonHighlight from "../shared/JsonHighlight";
34

45
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
56
started: { color: "var(--info)", label: "Started" },
@@ -56,12 +57,19 @@ function AttributeValue({ value }: { value: unknown }) {
5657
const [expanded, setExpanded] = useState(false);
5758
const raw = stringifyValue(value);
5859
const jsonFormatted = useMemo(() => tryParseJson(value), [value]);
60+
const isJson = jsonFormatted !== null;
5961
const displayValue = jsonFormatted ?? raw;
6062
const isLong = displayValue.length > TRUNCATE_LIMIT || displayValue.includes("\n");
6163
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
6264

6365
if (!isLong) {
64-
return (
66+
return isJson ? (
67+
<JsonHighlight
68+
json={displayValue}
69+
className="font-mono text-[11px] break-all whitespace-pre-wrap"
70+
style={{}}
71+
/>
72+
) : (
6573
<span className="font-mono text-[11px] break-all" style={{ color: "var(--text-primary)" }}>
6674
{displayValue}
6775
</span>
@@ -71,12 +79,20 @@ function AttributeValue({ value }: { value: unknown }) {
7179
return (
7280
<div>
7381
{expanded ? (
74-
<pre
75-
className="font-mono text-[11px] whitespace-pre-wrap break-all"
76-
style={{ color: "var(--text-primary)" }}
77-
>
78-
{displayValue}
79-
</pre>
82+
isJson ? (
83+
<JsonHighlight
84+
json={displayValue}
85+
className="font-mono text-[11px] whitespace-pre-wrap break-all"
86+
style={{}}
87+
/>
88+
) : (
89+
<pre
90+
className="font-mono text-[11px] whitespace-pre-wrap break-all"
91+
style={{ color: "var(--text-primary)" }}
92+
>
93+
{displayValue}
94+
</pre>
95+
)
8096
) : (
8197
<span className="font-mono text-[11px] break-all" style={{ color: "var(--text-primary)" }}>
8298
{displayValue.slice(0, TRUNCATE_LIMIT)}...

0 commit comments

Comments
 (0)