Skip to content

Commit 6db29f5

Browse files
author
王璨
committed
fix(web-ui): user messages, streaming reactivity, waiting timer
- Send user_message event before agent.prompt in web-backend - Fix React state immutability: always create new array/object refs in updateLastOrCreate to ensure re-renders on delta events - Add elapsed timer to Waiting and Thinking blocks - Keep processing=true between turns for waiting indicator - Set TCP_NODELAY on WebSocket for low-latency streaming
1 parent 1f0b8fa commit 6db29f5

4 files changed

Lines changed: 185 additions & 84 deletions

File tree

src/ui/web/web-backend.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,8 @@ export class WebUiBackend implements UiBackend {
257257
return;
258258
}
259259

260+
// Broadcast user message to client before sending to agent
261+
client.send({ type: "user_message", text } as any);
260262
try {
261263
const imageContents = this.pendingImages.length > 0 ? [...this.pendingImages] : undefined;
262264
this.pendingImages = [];

src/ui/web/ws-server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export class WsServer {
4545
this.wss = new WebSocketServer({ server: httpServer, path: "/ws" });
4646

4747
this.wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
48+
// Disable Nagle's algorithm for low-latency streaming
49+
const raw = (socket as any)._socket;
50+
if (raw) raw.setNoDelay(true);
51+
4852
const client = this.createClient(socket);
4953

5054
// Notify connection

web/src/components/App.tsx

Lines changed: 59 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { useState, useCallback, useRef, useEffect } from "react";
2-
import type { UIMessage, ServerEvent, Toast, ConfigData, SessionInfo, McpServerInfo, ToolCallEntry } from "../types";
2+
import type { UIMessage, ServerEvent, ConfigData, SessionInfo, McpServerInfo } from "../types";
33
import { useWebSocket } from "../hooks/useWebSocket";
44
import { ChatView } from "./ChatView";
55
import { MessageInput } from "./MessageInput";
66
import { Sidebar } from "./Sidebar";
7-
import { PermissionDialog } from "./PermissionDialog";
87
import { ToastContainer, useToasts } from "./Toast";
98

109
const SLASH_COMMANDS = [
@@ -22,6 +21,26 @@ const SLASH_COMMANDS = [
2221
{ name: "image", description: "Attach an image (file path or 'clipboard')" },
2322
];
2423

24+
/** Always produce a new array; update last message immutably if streaming, else push new. */
25+
function updateLastOrCreate(prev: UIMessage[], update: (msg: UIMessage) => Partial<UIMessage>): UIMessage[] {
26+
const next = [...prev];
27+
const last = next[next.length - 1];
28+
if (last?.isStreaming) {
29+
next[next.length - 1] = { ...last, ...update(last) };
30+
} else {
31+
next.push({
32+
id: `assistant-${Date.now()}`,
33+
role: "assistant",
34+
content: "",
35+
thinking: "",
36+
tools: [],
37+
isStreaming: true,
38+
...update({} as UIMessage),
39+
});
40+
}
41+
return next;
42+
}
43+
2544
export function App() {
2645
const [messages, setMessages] = useState<UIMessage[]>([]);
2746
const [processing, setProcessing] = useState(false);
@@ -37,10 +56,8 @@ export function App() {
3756
const [mcpServers, setMcpServers] = useState<McpServerInfo[]>([]);
3857
const { toasts, addToast, removeToast } = useToasts();
3958

40-
const messagesRef = useRef(messages);
41-
messagesRef.current = messages;
42-
43-
const permissionResolve = useRef<((decision: string, persistRule?: boolean) => void) | null>(null);
59+
// Track when the current assistant turn started (for timer)
60+
const turnStartRef = useRef<number>(0);
4461

4562
const handleEvent = useCallback((event: ServerEvent) => {
4663
switch (event.type) {
@@ -59,68 +76,44 @@ export function App() {
5976
}
6077

6178
case "user_message": {
62-
const msg: UIMessage = {
79+
setMessages((prev) => [...prev, {
6380
id: `user-${Date.now()}`,
6481
role: "user",
6582
content: event.text,
66-
};
67-
setMessages((prev) => [...prev, msg]);
83+
}]);
6884
break;
6985
}
7086

7187
case "assistant_start": {
72-
const msg: UIMessage = {
73-
id: `assistant-${Date.now()}`,
74-
role: "assistant",
75-
content: "",
76-
thinking: "",
77-
tools: [],
78-
isStreaming: true,
79-
};
80-
setMessages((prev) => [...prev, msg]);
88+
turnStartRef.current = Date.now();
8189
setProcessing(true);
8290
break;
8391
}
8492

8593
case "thinking_delta": {
86-
setMessages((prev) => {
87-
const next = [...prev];
88-
const last = next[next.length - 1];
89-
if (last?.isStreaming) {
90-
last.thinking = (last.thinking ?? "") + event.delta;
91-
}
92-
return next;
93-
});
94+
setMessages((prev) => updateLastOrCreate(prev, (msg) => ({
95+
thinking: (msg.thinking ?? "") + event.delta,
96+
})));
9497
break;
9598
}
9699

97100
case "text_delta": {
98-
setMessages((prev) => {
99-
const next = [...prev];
100-
const last = next[next.length - 1];
101-
if (last?.isStreaming) {
102-
last.content += event.delta;
103-
}
104-
return next;
105-
});
101+
setMessages((prev) => updateLastOrCreate(prev, (msg) => ({
102+
content: msg.content + event.delta,
103+
})));
106104
break;
107105
}
108106

109107
case "tool_start": {
110-
setMessages((prev) => {
111-
const next = [...prev];
112-
const last = next[next.length - 1];
113-
if (last?.isStreaming) {
114-
last.tools = last.tools ?? [];
115-
last.tools.push({
116-
name: event.name,
117-
args: typeof event.args === "string" ? event.args : JSON.stringify(event.args).slice(0, 80),
118-
result: "",
119-
isError: false,
120-
});
121-
}
122-
return next;
123-
});
108+
setMessages((prev) => updateLastOrCreate(prev, (msg) => {
109+
const tools = [...(msg.tools ?? []), {
110+
name: event.name,
111+
args: typeof event.args === "string" ? event.args : JSON.stringify(event.args).slice(0, 80),
112+
result: "",
113+
isError: false,
114+
}];
115+
return { tools };
116+
}));
124117
break;
125118
}
126119

@@ -129,13 +122,12 @@ export function App() {
129122
const next = [...prev];
130123
const last = next[next.length - 1];
131124
if (last?.isStreaming) {
132-
last.tools = last.tools ?? [];
133-
// Update the matching tool entry
134-
const toolEntry = last.tools[last.tools.length - 1];
135-
if (toolEntry && toolEntry.name === event.name && !toolEntry.result) {
136-
toolEntry.result = event.result;
137-
toolEntry.isError = event.isError;
138-
}
125+
const tools = (last.tools ?? []).map((t) =>
126+
t.name === event.name && !t.result
127+
? { ...t, result: event.result, isError: event.isError }
128+
: t
129+
);
130+
next[next.length - 1] = { ...last, tools };
139131
}
140132
return next;
141133
});
@@ -147,11 +139,11 @@ export function App() {
147139
const next = [...prev];
148140
const last = next[next.length - 1];
149141
if (last?.isStreaming) {
150-
last.isStreaming = false;
142+
next[next.length - 1] = { ...last, isStreaming: false };
151143
}
152144
return next;
153145
});
154-
setProcessing(false);
146+
// Keep processing=true — agent is still working between turns
155147
break;
156148
}
157149

@@ -163,6 +155,7 @@ export function App() {
163155
case "error": {
164156
addToast({ type: "error", text: event.text });
165157
setProcessing(false);
158+
turnStartRef.current = 0;
166159
break;
167160
}
168161

@@ -252,17 +245,17 @@ export function App() {
252245
[send],
253246
);
254247

255-
// Request sessions and MCP state on connect
256248
useEffect(() => {
257249
if (connected) {
258250
handleSessionAction("list");
259251
handleMcpAction("list");
260252
}
261253
}, [connected, handleSessionAction, handleMcpAction]);
262254

255+
const hasStreaming = messages.some((m) => m.isStreaming);
256+
263257
return (
264258
<div className="h-screen flex flex-col bg-dscode-bg">
265-
{/* Header */}
266259
<header className="flex items-center justify-between px-4 py-2 border-b border-dscode-border bg-dscode-surface shrink-0">
267260
<div className="flex items-center gap-3">
268261
<button
@@ -293,9 +286,7 @@ export function App() {
293286
</div>
294287
</header>
295288

296-
{/* Body */}
297289
<div className="flex flex-1 overflow-hidden">
298-
{/* Sidebar */}
299290
<Sidebar
300291
open={sidebarOpen}
301292
onClose={() => setSidebarOpen(false)}
@@ -309,9 +300,14 @@ export function App() {
309300
onConfigChange={handleConfigChange}
310301
/>
311302

312-
{/* Main chat area */}
313303
<main className="flex-1 flex flex-col min-w-0">
314-
<ChatView messages={messages} />
304+
<ChatView
305+
messages={messages}
306+
processing={processing}
307+
hasStreaming={hasStreaming}
308+
permissionPrompt={permissionPrompt}
309+
onPermission={handlePermission}
310+
/>
315311
<MessageInput
316312
onSend={handleSend}
317313
onAbort={handleAbort}
@@ -322,16 +318,6 @@ export function App() {
322318
</main>
323319
</div>
324320

325-
{/* Permission Dialog */}
326-
{permissionPrompt && (
327-
<PermissionDialog
328-
toolName={permissionPrompt.toolName}
329-
preview={permissionPrompt.preview}
330-
onDecision={handlePermission}
331-
/>
332-
)}
333-
334-
{/* Toast notifications */}
335321
<ToastContainer toasts={toasts} onRemove={removeToast} />
336322
</div>
337323
);

0 commit comments

Comments
 (0)