@hienlh/ppm 0.8.59 → 0.9.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/dist/web/assets/chat-tab-BDyjEN8p.js +7 -0
- package/dist/web/assets/{code-editor-DgTfBijB.js → code-editor-BmFI-Khj.js} +1 -1
- package/dist/web/assets/{database-viewer-DSlQhR7c.js → database-viewer-Cb7tqJqX.js} +1 -1
- package/dist/web/assets/{diff-viewer-C5A-ZnrC.js → diff-viewer-D_f9S4Ya.js} +1 -1
- package/dist/web/assets/{git-graph-B5QR_Cf-.js → git-graph-Co3a8y4i.js} +1 -1
- package/dist/web/assets/index-BAioKo_2.css +2 -0
- package/dist/web/assets/{index-frRaTxEm.js → index-CqMDTnLp.js} +3 -3
- package/dist/web/assets/keybindings-store-CulLCWPX.js +1 -0
- package/dist/web/assets/{markdown-renderer-DK-YZN0m.js → markdown-renderer-xipSjvIr.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CV0kVl2C.js → postgres-viewer-p5tz14oN.js} +1 -1
- package/dist/web/assets/{settings-tab-DofusrxH.js → settings-tab-CCz5ftre.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-D5L6DIMB.js → sqlite-viewer-vF9L6tfk.js} +1 -1
- package/dist/web/assets/{terminal-tab-lu-7WWOT.js → terminal-tab-XbV1JFTB.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +16 -14
- package/src/providers/mock-provider.ts +6 -1
- package/src/server/ws/chat.ts +194 -139
- package/src/types/api.ts +9 -1
- package/src/web/components/chat/chat-tab.tsx +14 -5
- package/src/web/components/chat/message-list.tsx +15 -12
- package/src/web/hooks/use-chat.ts +196 -203
- package/dist/web/assets/chat-tab-CM6zFolq.js +0 -7
- package/dist/web/assets/index-WKLuYsBY.css +0 -2
- package/dist/web/assets/keybindings-store-Bjy78BoD.js +0 -1
|
@@ -5,7 +5,7 @@ import { useNotificationStore } from "@/stores/notification-store";
|
|
|
5
5
|
import { usePanelStore } from "@/stores/panel-store";
|
|
6
6
|
import { playNotificationSound } from "@/lib/notification-sounds";
|
|
7
7
|
import type { ChatMessage, ChatEvent } from "../../types/chat";
|
|
8
|
-
import type { ChatWsServerMessage } from "../../types/api";
|
|
8
|
+
import type { ChatWsServerMessage, SessionPhase } from "../../types/api";
|
|
9
9
|
|
|
10
10
|
interface ApprovalRequest {
|
|
11
11
|
requestId: string;
|
|
@@ -13,20 +13,15 @@ interface ApprovalRequest {
|
|
|
13
13
|
input: unknown;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/** Streaming phase: connecting → streaming → idle */
|
|
17
|
-
export type StreamingStatus = "idle" | "connecting" | "streaming";
|
|
18
|
-
|
|
19
16
|
interface UseChatReturn {
|
|
20
17
|
messages: ChatMessage[];
|
|
21
18
|
messagesLoading: boolean;
|
|
22
19
|
isStreaming: boolean;
|
|
23
|
-
|
|
20
|
+
phase: SessionPhase;
|
|
21
|
+
isReconnecting: boolean;
|
|
24
22
|
connectingElapsed: number;
|
|
25
|
-
thinkingWarningThreshold: number;
|
|
26
23
|
pendingApproval: ApprovalRequest | null;
|
|
27
|
-
/** Context window usage % from last completed query (0–100) */
|
|
28
24
|
contextWindowPct: number | null;
|
|
29
|
-
/** Updated session title from SDK summary (set after stream completes) */
|
|
30
25
|
sessionTitle: string | null;
|
|
31
26
|
sendMessage: (content: string, opts?: { permissionMode?: string }) => void;
|
|
32
27
|
respondToApproval: (requestId: string, approved: boolean, data?: unknown) => void;
|
|
@@ -49,12 +44,9 @@ function isSessionTabActive(sid: string): boolean {
|
|
|
49
44
|
export function useChat(sessionId: string | null, providerId = "claude", projectName = ""): UseChatReturn {
|
|
50
45
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
51
46
|
const [messagesLoading, setMessagesLoading] = useState(false);
|
|
52
|
-
const [
|
|
53
|
-
const [
|
|
54
|
-
/** Elapsed seconds while connecting (sent by BE heartbeat every 5s) */
|
|
47
|
+
const [phase, setPhase] = useState<SessionPhase>("idle");
|
|
48
|
+
const [isReconnecting, setIsReconnecting] = useState(false);
|
|
55
49
|
const [connectingElapsed, setConnectingElapsed] = useState(0);
|
|
56
|
-
/** Warning threshold in seconds — higher for deeper thinking modes */
|
|
57
|
-
const [thinkingWarningThreshold, setThinkingWarningThreshold] = useState(15);
|
|
58
50
|
const [pendingApproval, setPendingApproval] = useState<ApprovalRequest | null>(null);
|
|
59
51
|
const [contextWindowPct, setContextWindowPct] = useState<number | null>(null);
|
|
60
52
|
const [sessionTitle, setSessionTitle] = useState<string | null>(null);
|
|
@@ -62,188 +54,124 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
62
54
|
const streamingContentRef = useRef("");
|
|
63
55
|
const streamingEventsRef = useRef<ChatEvent[]>([]);
|
|
64
56
|
const streamingAccountRef = useRef<{ accountId: string; accountLabel: string } | null>(null);
|
|
65
|
-
const
|
|
57
|
+
const phaseRef = useRef<SessionPhase>("idle");
|
|
66
58
|
const pendingMessageRef = useRef<string | null>(null);
|
|
67
59
|
const sendRef = useRef<(data: string) => void>(() => {});
|
|
68
60
|
const refetchRef = useRef<(() => void) | null>(null);
|
|
69
|
-
// Refs for notification dispatch inside handleMessage (which has [] deps)
|
|
70
61
|
const sessionIdRef = useRef(sessionId);
|
|
71
62
|
sessionIdRef.current = sessionId;
|
|
72
63
|
const projectNameRef = useRef(projectName);
|
|
73
64
|
projectNameRef.current = projectName;
|
|
74
65
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
setConnectingElapsed(s === "connecting" ? ((data as any).elapsed ?? 0) : 0);
|
|
97
|
-
// Compute warning threshold based on effort/thinking budget
|
|
98
|
-
if (s === "connecting") {
|
|
99
|
-
const effort = (data as any).effort as string | undefined;
|
|
100
|
-
const budget = (data as any).thinkingBudget as number | undefined;
|
|
101
|
-
// Higher thinking = longer acceptable wait time
|
|
102
|
-
let threshold = 15; // default
|
|
103
|
-
if (budget && budget > 0) {
|
|
104
|
-
// Rough: 1k tokens ≈ 2s thinking time
|
|
105
|
-
threshold = Math.max(15, Math.round(budget / 500));
|
|
106
|
-
} else if (effort === "high") {
|
|
107
|
-
threshold = 30;
|
|
108
|
-
} else if (effort === "low") {
|
|
109
|
-
threshold = 10;
|
|
110
|
-
}
|
|
111
|
-
setThinkingWarningThreshold(threshold);
|
|
112
|
-
}
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Handle connected event (new session)
|
|
117
|
-
if ((data as any).type === "connected") {
|
|
118
|
-
setIsConnected(true);
|
|
119
|
-
if ((data as any).sessionTitle) setSessionTitle((data as any).sessionTitle);
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
66
|
+
// Derived state
|
|
67
|
+
const isStreaming = phase !== "idle";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Route a child event to its parent Agent/Task tool_use's children array.
|
|
71
|
+
* Creates a new parent object to ensure React detects the change on re-render.
|
|
72
|
+
* Returns true if routed (caller should skip flat append), false if no parent found.
|
|
73
|
+
*/
|
|
74
|
+
const routeToParent = useCallback((childEvent: ChatEvent, parentToolUseId: string): boolean => {
|
|
75
|
+
const idx = streamingEventsRef.current.findIndex(
|
|
76
|
+
(e) => e.type === "tool_use"
|
|
77
|
+
&& (e.tool === "Agent" || e.tool === "Task")
|
|
78
|
+
&& (e as any).toolUseId === parentToolUseId,
|
|
79
|
+
);
|
|
80
|
+
if (idx === -1) return false;
|
|
81
|
+
const parent = streamingEventsRef.current[idx]!;
|
|
82
|
+
if (parent.type !== "tool_use") return false;
|
|
83
|
+
const newChildren = [...(parent.children ?? []), childEvent];
|
|
84
|
+
streamingEventsRef.current[idx] = { ...parent, children: newChildren };
|
|
85
|
+
return true;
|
|
86
|
+
}, []);
|
|
122
87
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (status.pendingApproval) {
|
|
133
|
-
setPendingApproval({
|
|
134
|
-
requestId: status.pendingApproval.requestId,
|
|
135
|
-
tool: status.pendingApproval.tool,
|
|
136
|
-
input: status.pendingApproval.input,
|
|
137
|
-
});
|
|
88
|
+
/** Trigger re-render with latest events snapshot */
|
|
89
|
+
const syncMessages = useCallback(() => {
|
|
90
|
+
const content = streamingContentRef.current;
|
|
91
|
+
const events = [...streamingEventsRef.current];
|
|
92
|
+
const account = streamingAccountRef.current;
|
|
93
|
+
setMessages((prev) => {
|
|
94
|
+
const last = prev[prev.length - 1];
|
|
95
|
+
if (last?.role === "assistant" && !last.id.startsWith("final-")) {
|
|
96
|
+
return [...prev.slice(0, -1), { ...last, content, events, ...account }];
|
|
138
97
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
98
|
+
return [...prev, {
|
|
99
|
+
id: `streaming-${Date.now()}`,
|
|
100
|
+
role: "assistant" as const,
|
|
101
|
+
content,
|
|
102
|
+
events,
|
|
103
|
+
timestamp: new Date().toISOString(),
|
|
104
|
+
...account,
|
|
105
|
+
}];
|
|
106
|
+
});
|
|
107
|
+
}, []);
|
|
143
108
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const routeToParent = (childEvent: ChatEvent, parentToolUseId: string): boolean => {
|
|
150
|
-
const idx = streamingEventsRef.current.findIndex(
|
|
151
|
-
(e) => e.type === "tool_use"
|
|
152
|
-
&& (e.tool === "Agent" || e.tool === "Task")
|
|
153
|
-
&& (e as any).toolUseId === parentToolUseId,
|
|
154
|
-
);
|
|
155
|
-
if (idx === -1) return false;
|
|
156
|
-
const parent = streamingEventsRef.current[idx]!;
|
|
157
|
-
if (parent.type !== "tool_use") return false;
|
|
158
|
-
// Create new object so React detects the change via shallow comparison
|
|
159
|
-
const newChildren = [...(parent.children ?? []), childEvent];
|
|
160
|
-
streamingEventsRef.current[idx] = { ...parent, children: newChildren };
|
|
161
|
-
return true;
|
|
162
|
-
};
|
|
109
|
+
/** Process a single stream event — reused by live events and turn_events replay */
|
|
110
|
+
const processStreamEvent = useCallback((data: unknown) => {
|
|
111
|
+
const ev = data as any;
|
|
112
|
+
const evType = ev?.type;
|
|
113
|
+
if (!evType) return;
|
|
163
114
|
|
|
164
|
-
|
|
165
|
-
const syncMessages = () => {
|
|
166
|
-
const content = streamingContentRef.current;
|
|
167
|
-
const events = [...streamingEventsRef.current];
|
|
168
|
-
const account = streamingAccountRef.current;
|
|
169
|
-
setMessages((prev) => {
|
|
170
|
-
const last = prev[prev.length - 1];
|
|
171
|
-
if (last?.role === "assistant" && !last.id.startsWith("final-")) {
|
|
172
|
-
return [...prev.slice(0, -1), { ...last, content, events, ...account }];
|
|
173
|
-
}
|
|
174
|
-
return [...prev, {
|
|
175
|
-
id: `streaming-${Date.now()}`,
|
|
176
|
-
role: "assistant" as const,
|
|
177
|
-
content,
|
|
178
|
-
events,
|
|
179
|
-
timestamp: new Date().toISOString(),
|
|
180
|
-
...account,
|
|
181
|
-
}];
|
|
182
|
-
});
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
switch (data.type) {
|
|
115
|
+
switch (evType) {
|
|
186
116
|
case "account_info": {
|
|
187
|
-
streamingAccountRef.current = { accountId:
|
|
117
|
+
streamingAccountRef.current = { accountId: ev.accountId, accountLabel: ev.accountLabel };
|
|
188
118
|
break;
|
|
189
119
|
}
|
|
190
120
|
|
|
191
121
|
case "text": {
|
|
192
|
-
const pid =
|
|
193
|
-
if (pid && routeToParent(
|
|
194
|
-
// Child text routed to parent — just re-render
|
|
122
|
+
const pid = ev.parentToolUseId as string | undefined;
|
|
123
|
+
if (pid && routeToParent(ev as ChatEvent, pid)) {
|
|
195
124
|
syncMessages();
|
|
196
125
|
break;
|
|
197
126
|
}
|
|
198
|
-
streamingContentRef.current +=
|
|
199
|
-
streamingEventsRef.current.push(
|
|
127
|
+
streamingContentRef.current += ev.content;
|
|
128
|
+
streamingEventsRef.current.push(ev as ChatEvent);
|
|
200
129
|
syncMessages();
|
|
201
130
|
break;
|
|
202
131
|
}
|
|
203
132
|
|
|
204
133
|
case "thinking": {
|
|
205
|
-
const pid =
|
|
206
|
-
if (pid && routeToParent(
|
|
134
|
+
const pid = ev.parentToolUseId as string | undefined;
|
|
135
|
+
if (pid && routeToParent(ev as ChatEvent, pid)) {
|
|
207
136
|
syncMessages();
|
|
208
137
|
break;
|
|
209
138
|
}
|
|
210
|
-
streamingEventsRef.current.push(
|
|
139
|
+
streamingEventsRef.current.push(ev as ChatEvent);
|
|
211
140
|
syncMessages();
|
|
212
141
|
break;
|
|
213
142
|
}
|
|
214
143
|
|
|
215
144
|
case "tool_use": {
|
|
216
|
-
const pid =
|
|
217
|
-
if (pid && routeToParent(
|
|
145
|
+
const pid = ev.parentToolUseId as string | undefined;
|
|
146
|
+
if (pid && routeToParent(ev as ChatEvent, pid)) {
|
|
218
147
|
syncMessages();
|
|
219
148
|
break;
|
|
220
149
|
}
|
|
221
|
-
streamingEventsRef.current.push(
|
|
150
|
+
streamingEventsRef.current.push(ev as ChatEvent);
|
|
222
151
|
syncMessages();
|
|
223
152
|
break;
|
|
224
153
|
}
|
|
225
154
|
|
|
226
155
|
case "tool_result": {
|
|
227
|
-
const pid =
|
|
228
|
-
if (pid && routeToParent(
|
|
156
|
+
const pid = ev.parentToolUseId as string | undefined;
|
|
157
|
+
if (pid && routeToParent(ev as ChatEvent, pid)) {
|
|
229
158
|
syncMessages();
|
|
230
159
|
break;
|
|
231
160
|
}
|
|
232
|
-
streamingEventsRef.current.push(
|
|
161
|
+
streamingEventsRef.current.push(ev as ChatEvent);
|
|
233
162
|
syncMessages();
|
|
234
163
|
break;
|
|
235
164
|
}
|
|
236
165
|
|
|
237
166
|
case "approval_request": {
|
|
238
|
-
streamingEventsRef.current.push(
|
|
167
|
+
streamingEventsRef.current.push(ev as ChatEvent);
|
|
239
168
|
setPendingApproval({
|
|
240
|
-
requestId:
|
|
241
|
-
tool:
|
|
242
|
-
input:
|
|
169
|
+
requestId: ev.requestId,
|
|
170
|
+
tool: ev.tool,
|
|
171
|
+
input: ev.input,
|
|
243
172
|
});
|
|
244
|
-
// Local notification badge — only if this tab is NOT active
|
|
245
173
|
if (sessionIdRef.current && !isSessionTabActive(sessionIdRef.current)) {
|
|
246
|
-
const nType =
|
|
174
|
+
const nType = ev.tool === "AskUserQuestion" ? "question" : "approval_request";
|
|
247
175
|
useNotificationStore.getState().addNotification(sessionIdRef.current, nType, projectNameRef.current);
|
|
248
176
|
playNotificationSound(nType);
|
|
249
177
|
}
|
|
@@ -251,73 +179,147 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
251
179
|
}
|
|
252
180
|
|
|
253
181
|
case "error": {
|
|
254
|
-
streamingEventsRef.current.push(
|
|
182
|
+
streamingEventsRef.current.push(ev as ChatEvent);
|
|
255
183
|
const errEvents = [...streamingEventsRef.current];
|
|
256
184
|
setMessages((prev) => {
|
|
257
185
|
const last = prev[prev.length - 1];
|
|
258
186
|
if (last?.role === "assistant") {
|
|
259
|
-
return [
|
|
260
|
-
...prev.slice(0, -1),
|
|
261
|
-
{ ...last, events: errEvents },
|
|
262
|
-
];
|
|
187
|
+
return [...prev.slice(0, -1), { ...last, events: errEvents }];
|
|
263
188
|
}
|
|
264
|
-
return [
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
timestamp: new Date().toISOString(),
|
|
272
|
-
},
|
|
273
|
-
];
|
|
189
|
+
return [...prev, {
|
|
190
|
+
id: `error-${Date.now()}`,
|
|
191
|
+
role: "system" as const,
|
|
192
|
+
content: ev.message,
|
|
193
|
+
events: [ev as ChatEvent],
|
|
194
|
+
timestamp: new Date().toISOString(),
|
|
195
|
+
}];
|
|
274
196
|
});
|
|
275
|
-
|
|
276
|
-
setIsStreaming(false);
|
|
277
|
-
setStreamingStatus("idle");
|
|
197
|
+
// Phase reset comes from BE via phase_changed
|
|
278
198
|
break;
|
|
279
199
|
}
|
|
280
200
|
|
|
281
201
|
case "done": {
|
|
282
202
|
// Idempotent: may receive duplicate done (provider + stream loop finally)
|
|
283
|
-
if (
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
setContextWindowPct(data.contextWindowPct);
|
|
203
|
+
if (phaseRef.current === "idle") break;
|
|
204
|
+
if (ev.contextWindowPct != null) {
|
|
205
|
+
setContextWindowPct(ev.contextWindowPct);
|
|
287
206
|
}
|
|
288
|
-
// Local notification badge — only if this tab is NOT active
|
|
289
207
|
if (sessionIdRef.current && !isSessionTabActive(sessionIdRef.current)) {
|
|
290
208
|
useNotificationStore.getState().addNotification(sessionIdRef.current, "done", projectNameRef.current);
|
|
291
209
|
playNotificationSound("done");
|
|
292
210
|
}
|
|
293
|
-
// Finalize the streaming message
|
|
211
|
+
// Finalize the streaming message
|
|
294
212
|
const finalContent = streamingContentRef.current;
|
|
295
213
|
const finalEvents = [...streamingEventsRef.current];
|
|
296
214
|
setMessages((prev) => {
|
|
297
215
|
const last = prev[prev.length - 1];
|
|
298
216
|
if (last?.role === "assistant") {
|
|
299
|
-
return [
|
|
300
|
-
...
|
|
301
|
-
{
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
events: finalEvents.length > 0 ? finalEvents : last.events,
|
|
306
|
-
},
|
|
307
|
-
];
|
|
217
|
+
return [...prev.slice(0, -1), {
|
|
218
|
+
...last,
|
|
219
|
+
id: `final-${Date.now()}`,
|
|
220
|
+
content: finalContent || last.content,
|
|
221
|
+
events: finalEvents.length > 0 ? finalEvents : last.events,
|
|
222
|
+
}];
|
|
308
223
|
}
|
|
309
224
|
return prev;
|
|
310
225
|
});
|
|
311
226
|
streamingContentRef.current = "";
|
|
312
227
|
streamingEventsRef.current = [];
|
|
313
228
|
streamingAccountRef.current = null;
|
|
314
|
-
|
|
315
|
-
setIsStreaming(false);
|
|
316
|
-
setStreamingStatus("idle");
|
|
229
|
+
// Phase transition to idle comes from BE via phase_changed
|
|
317
230
|
break;
|
|
318
231
|
}
|
|
319
232
|
}
|
|
320
|
-
}, []);
|
|
233
|
+
}, [routeToParent, syncMessages]);
|
|
234
|
+
|
|
235
|
+
const handleMessage = useCallback((event: MessageEvent) => {
|
|
236
|
+
let data: ChatWsServerMessage;
|
|
237
|
+
try {
|
|
238
|
+
data = JSON.parse(event.data as string) as ChatWsServerMessage;
|
|
239
|
+
} catch {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Ignore keepalive pings
|
|
244
|
+
if ((data as any).type === "ping") return;
|
|
245
|
+
|
|
246
|
+
// Handle title updates from SDK summary
|
|
247
|
+
if ((data as any).type === "title_updated") {
|
|
248
|
+
setSessionTitle((data as any).title ?? null);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Handle phase transitions from BE
|
|
253
|
+
if ((data as any).type === "phase_changed") {
|
|
254
|
+
const p = (data as any).phase as SessionPhase;
|
|
255
|
+
setPhase(p);
|
|
256
|
+
phaseRef.current = p;
|
|
257
|
+
setConnectingElapsed(p === "connecting" ? ((data as any).elapsed ?? 0) : 0);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Handle session state (replaces connected + status)
|
|
262
|
+
if ((data as any).type === "session_state") {
|
|
263
|
+
setIsConnected(true);
|
|
264
|
+
const state = data as any;
|
|
265
|
+
const p = state.phase as SessionPhase;
|
|
266
|
+
setPhase(p);
|
|
267
|
+
phaseRef.current = p;
|
|
268
|
+
if (state.sessionTitle) setSessionTitle(state.sessionTitle);
|
|
269
|
+
if (state.pendingApproval) {
|
|
270
|
+
setPendingApproval({
|
|
271
|
+
requestId: state.pendingApproval.requestId,
|
|
272
|
+
tool: state.pendingApproval.tool,
|
|
273
|
+
input: state.pendingApproval.input,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
// If idle, refetch history (completed turns) and hide overlay
|
|
277
|
+
if (p === "idle") {
|
|
278
|
+
refetchRef.current?.();
|
|
279
|
+
setIsReconnecting(false);
|
|
280
|
+
}
|
|
281
|
+
// If streaming, turn_events message will follow
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Handle turn_events (reconnect sync with rAF chunking)
|
|
286
|
+
if ((data as any).type === "turn_events") {
|
|
287
|
+
const events = (data as any).events as unknown[];
|
|
288
|
+
if (!events?.length) { setIsReconnecting(false); return; }
|
|
289
|
+
|
|
290
|
+
// Truncate messages after last user message
|
|
291
|
+
setMessages(prev => {
|
|
292
|
+
const lastUserIdx = prev.findLastIndex(m => m.role === "user");
|
|
293
|
+
return lastUserIdx >= 0 ? prev.slice(0, lastUserIdx + 1) : prev;
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Reset streaming refs
|
|
297
|
+
streamingContentRef.current = "";
|
|
298
|
+
streamingEventsRef.current = [];
|
|
299
|
+
streamingAccountRef.current = null;
|
|
300
|
+
|
|
301
|
+
// Process events in chunks via requestAnimationFrame to avoid blocking main thread
|
|
302
|
+
const CHUNK_SIZE = 100;
|
|
303
|
+
let offset = 0;
|
|
304
|
+
const processChunk = () => {
|
|
305
|
+
const end = Math.min(offset + CHUNK_SIZE, events.length);
|
|
306
|
+
for (let i = offset; i < end; i++) {
|
|
307
|
+
processStreamEvent(events[i]);
|
|
308
|
+
}
|
|
309
|
+
offset = end;
|
|
310
|
+
if (offset < events.length) {
|
|
311
|
+
requestAnimationFrame(processChunk);
|
|
312
|
+
} else {
|
|
313
|
+
setIsReconnecting(false);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
requestAnimationFrame(processChunk);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Route content events through processStreamEvent
|
|
321
|
+
processStreamEvent(data);
|
|
322
|
+
}, [processStreamEvent]);
|
|
321
323
|
|
|
322
324
|
const wsUrl = sessionId && projectName
|
|
323
325
|
? `/ws/project/${encodeURIComponent(projectName)}/chat/${sessionId}`
|
|
@@ -336,21 +338,21 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
336
338
|
useEffect(() => {
|
|
337
339
|
let cancelled = false;
|
|
338
340
|
|
|
339
|
-
|
|
341
|
+
setPhase("idle");
|
|
342
|
+
phaseRef.current = "idle";
|
|
340
343
|
setPendingApproval(null);
|
|
341
344
|
streamingContentRef.current = "";
|
|
342
345
|
streamingEventsRef.current = [];
|
|
343
346
|
setIsConnected(false);
|
|
344
347
|
|
|
345
348
|
if (sessionId && projectName) {
|
|
346
|
-
// Load message history
|
|
347
349
|
setMessagesLoading(true);
|
|
348
350
|
fetch(`${projectUrl(projectName)}/chat/sessions/${sessionId}/messages?providerId=${providerId}`, {
|
|
349
351
|
headers: { Authorization: `Bearer ${getAuthToken()}` },
|
|
350
352
|
})
|
|
351
353
|
.then((r) => r.json())
|
|
352
354
|
.then((json: any) => {
|
|
353
|
-
if (cancelled ||
|
|
355
|
+
if (cancelled || phaseRef.current !== "idle") return;
|
|
354
356
|
if (json.ok && Array.isArray(json.data) && json.data.length > 0) {
|
|
355
357
|
setMessages(json.data);
|
|
356
358
|
} else {
|
|
@@ -358,7 +360,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
358
360
|
}
|
|
359
361
|
})
|
|
360
362
|
.catch(() => {
|
|
361
|
-
if (!cancelled &&
|
|
363
|
+
if (!cancelled && phaseRef.current === "idle") setMessages([]);
|
|
362
364
|
})
|
|
363
365
|
.finally(() => {
|
|
364
366
|
if (!cancelled) setMessagesLoading(false);
|
|
@@ -377,8 +379,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
377
379
|
if (!content.trim()) return;
|
|
378
380
|
|
|
379
381
|
// If streaming, cancel current stream first then send immediately
|
|
380
|
-
if (
|
|
381
|
-
// Finalize current streaming message
|
|
382
|
+
if (phaseRef.current !== "idle") {
|
|
382
383
|
const finalContent = streamingContentRef.current;
|
|
383
384
|
const finalEvents = [...streamingEventsRef.current];
|
|
384
385
|
setMessages((prev) => {
|
|
@@ -391,7 +392,6 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
391
392
|
}
|
|
392
393
|
return prev;
|
|
393
394
|
});
|
|
394
|
-
// Tell backend to abort current query
|
|
395
395
|
send(JSON.stringify({ type: "cancel" }));
|
|
396
396
|
}
|
|
397
397
|
|
|
@@ -410,9 +410,8 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
410
410
|
streamingContentRef.current = "";
|
|
411
411
|
streamingEventsRef.current = [];
|
|
412
412
|
pendingMessageRef.current = null;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
setStreamingStatus("connecting");
|
|
413
|
+
setPhase("initializing");
|
|
414
|
+
phaseRef.current = "initializing";
|
|
416
415
|
setPendingApproval(null);
|
|
417
416
|
|
|
418
417
|
send(JSON.stringify({ type: "message", content, permissionMode: opts?.permissionMode }));
|
|
@@ -441,13 +440,11 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
441
440
|
(e as any).tool === "AskUserQuestion",
|
|
442
441
|
);
|
|
443
442
|
if (askEvt) {
|
|
444
|
-
// Mutate input to include answers — this updates the rendered ToolCard
|
|
445
443
|
const inp = (askEvt as any).input;
|
|
446
444
|
if (inp && typeof inp === "object") {
|
|
447
445
|
(inp as Record<string, unknown>).answers = data;
|
|
448
446
|
}
|
|
449
447
|
}
|
|
450
|
-
// Force re-render messages
|
|
451
448
|
setMessages((prev) => [...prev]);
|
|
452
449
|
}
|
|
453
450
|
|
|
@@ -457,10 +454,8 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
457
454
|
);
|
|
458
455
|
|
|
459
456
|
const cancelStreaming = useCallback(() => {
|
|
460
|
-
if (
|
|
461
|
-
// Tell backend to abort
|
|
457
|
+
if (phaseRef.current === "idle") return;
|
|
462
458
|
send(JSON.stringify({ type: "cancel" }));
|
|
463
|
-
// Finalize current message on FE
|
|
464
459
|
const finalContent = streamingContentRef.current;
|
|
465
460
|
const finalEvents = [...streamingEventsRef.current];
|
|
466
461
|
setMessages((prev) => {
|
|
@@ -481,16 +476,15 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
481
476
|
streamingContentRef.current = "";
|
|
482
477
|
streamingEventsRef.current = [];
|
|
483
478
|
pendingMessageRef.current = null;
|
|
484
|
-
|
|
485
|
-
|
|
479
|
+
setPhase("idle");
|
|
480
|
+
phaseRef.current = "idle";
|
|
486
481
|
setPendingApproval(null);
|
|
487
482
|
}, [send]);
|
|
488
483
|
|
|
489
484
|
const reconnect = useCallback(() => {
|
|
490
485
|
setIsConnected(false);
|
|
486
|
+
setIsReconnecting(true);
|
|
491
487
|
wsReconnect();
|
|
492
|
-
// Refetch history on manual reconnect to catch up on missed events
|
|
493
|
-
refetchRef.current?.();
|
|
494
488
|
}, [wsReconnect]);
|
|
495
489
|
|
|
496
490
|
const refetchMessages = useCallback(() => {
|
|
@@ -503,7 +497,6 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
503
497
|
.then((json: any) => {
|
|
504
498
|
if (json.ok && Array.isArray(json.data) && json.data.length > 0) {
|
|
505
499
|
setMessages(json.data);
|
|
506
|
-
// Reset streaming content refs so live tokens append cleanly after history
|
|
507
500
|
streamingContentRef.current = "";
|
|
508
501
|
streamingEventsRef.current = [];
|
|
509
502
|
}
|
|
@@ -512,16 +505,16 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
512
505
|
.finally(() => setMessagesLoading(false));
|
|
513
506
|
}, [sessionId, providerId, projectName]);
|
|
514
507
|
|
|
515
|
-
// Keep refetchRef in sync
|
|
508
|
+
// Keep refetchRef in sync
|
|
516
509
|
refetchRef.current = refetchMessages;
|
|
517
510
|
|
|
518
511
|
return {
|
|
519
512
|
messages,
|
|
520
513
|
messagesLoading,
|
|
521
514
|
isStreaming,
|
|
522
|
-
|
|
515
|
+
phase,
|
|
516
|
+
isReconnecting,
|
|
523
517
|
connectingElapsed,
|
|
524
|
-
thinkingWarningThreshold,
|
|
525
518
|
pendingApproval,
|
|
526
519
|
contextWindowPct,
|
|
527
520
|
sessionTitle,
|