@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.
Files changed (27) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/web/assets/chat-tab-BDyjEN8p.js +7 -0
  3. package/dist/web/assets/{code-editor-DgTfBijB.js → code-editor-BmFI-Khj.js} +1 -1
  4. package/dist/web/assets/{database-viewer-DSlQhR7c.js → database-viewer-Cb7tqJqX.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-C5A-ZnrC.js → diff-viewer-D_f9S4Ya.js} +1 -1
  6. package/dist/web/assets/{git-graph-B5QR_Cf-.js → git-graph-Co3a8y4i.js} +1 -1
  7. package/dist/web/assets/index-BAioKo_2.css +2 -0
  8. package/dist/web/assets/{index-frRaTxEm.js → index-CqMDTnLp.js} +3 -3
  9. package/dist/web/assets/keybindings-store-CulLCWPX.js +1 -0
  10. package/dist/web/assets/{markdown-renderer-DK-YZN0m.js → markdown-renderer-xipSjvIr.js} +1 -1
  11. package/dist/web/assets/{postgres-viewer-CV0kVl2C.js → postgres-viewer-p5tz14oN.js} +1 -1
  12. package/dist/web/assets/{settings-tab-DofusrxH.js → settings-tab-CCz5ftre.js} +1 -1
  13. package/dist/web/assets/{sqlite-viewer-D5L6DIMB.js → sqlite-viewer-vF9L6tfk.js} +1 -1
  14. package/dist/web/assets/{terminal-tab-lu-7WWOT.js → terminal-tab-XbV1JFTB.js} +1 -1
  15. package/dist/web/index.html +2 -2
  16. package/dist/web/sw.js +1 -1
  17. package/package.json +1 -1
  18. package/src/providers/claude-agent-sdk.ts +16 -14
  19. package/src/providers/mock-provider.ts +6 -1
  20. package/src/server/ws/chat.ts +194 -139
  21. package/src/types/api.ts +9 -1
  22. package/src/web/components/chat/chat-tab.tsx +14 -5
  23. package/src/web/components/chat/message-list.tsx +15 -12
  24. package/src/web/hooks/use-chat.ts +196 -203
  25. package/dist/web/assets/chat-tab-CM6zFolq.js +0 -7
  26. package/dist/web/assets/index-WKLuYsBY.css +0 -2
  27. 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
- streamingStatus: StreamingStatus;
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 [isStreaming, setIsStreaming] = useState(false);
53
- const [streamingStatus, setStreamingStatus] = useState<StreamingStatus>("idle");
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 isStreamingRef = useRef(false);
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
- const handleMessage = useCallback((event: MessageEvent) => {
76
- let data: ChatWsServerMessage;
77
- try {
78
- data = JSON.parse(event.data as string) as ChatWsServerMessage;
79
- } catch {
80
- return;
81
- }
82
-
83
- // Ignore keepalive pings
84
- if ((data as any).type === "ping") return;
85
-
86
- // Handle title updates from SDK summary
87
- if ((data as any).type === "title_updated") {
88
- setSessionTitle((data as any).title ?? null);
89
- return;
90
- }
91
-
92
- // Handle streaming status updates (connecting streaming → idle)
93
- if ((data as any).type === "streaming_status") {
94
- const s = (data as any).status ?? "idle";
95
- setStreamingStatus(s);
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
- // Handle status event (FE reconnected to existing session)
124
- if ((data as any).type === "status") {
125
- setIsConnected(true);
126
- const status = data as any;
127
- if (status.sessionTitle) setSessionTitle(status.sessionTitle);
128
- if (status.isStreaming) {
129
- isStreamingRef.current = true;
130
- setIsStreaming(true);
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
- // Refetch history to catch up on events missed during disconnect
140
- refetchRef.current?.();
141
- return;
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
- * Route a child event to its parent Agent/Task tool_use's children array.
146
- * Creates a new parent object to ensure React detects the change on re-render.
147
- * Returns true if routed (caller should skip flat append), false if no parent found.
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
- /** Trigger re-render with latest events snapshot */
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: (data as any).accountId, accountLabel: (data as any).accountLabel };
117
+ streamingAccountRef.current = { accountId: ev.accountId, accountLabel: ev.accountLabel };
188
118
  break;
189
119
  }
190
120
 
191
121
  case "text": {
192
- const pid = (data as any).parentToolUseId as string | undefined;
193
- if (pid && routeToParent(data, pid)) {
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 += data.content;
199
- streamingEventsRef.current.push(data);
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 = (data as any).parentToolUseId as string | undefined;
206
- if (pid && routeToParent(data, pid)) {
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(data);
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 = (data as any).parentToolUseId as string | undefined;
217
- if (pid && routeToParent(data, pid)) {
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(data);
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 = (data as any).parentToolUseId as string | undefined;
228
- if (pid && routeToParent(data, pid)) {
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(data);
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(data);
167
+ streamingEventsRef.current.push(ev as ChatEvent);
239
168
  setPendingApproval({
240
- requestId: data.requestId,
241
- tool: data.tool,
242
- input: data.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 = data.tool === "AskUserQuestion" ? "question" : "approval_request";
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(data);
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
- ...prev,
266
- {
267
- id: `error-${Date.now()}`,
268
- role: "system" as const,
269
- content: data.message,
270
- events: [data],
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
- isStreamingRef.current = false;
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 (!isStreamingRef.current) break;
284
- // Capture context window usage from SDK result
285
- if (data.contextWindowPct != null) {
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 — capture refs before clearing
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
- ...prev.slice(0, -1),
301
- {
302
- ...last,
303
- id: `final-${Date.now()}`,
304
- content: finalContent || last.content,
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
- isStreamingRef.current = false;
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
- setIsStreaming(false);
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 || isStreamingRef.current) return;
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 && !isStreamingRef.current) setMessages([]);
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 (isStreamingRef.current) {
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
- isStreamingRef.current = true;
414
- setIsStreaming(true);
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 (!isStreamingRef.current) return;
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
- isStreamingRef.current = false;
485
- setIsStreaming(false);
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 so handleMessage (status event) can trigger refetch
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
- streamingStatus,
515
+ phase,
516
+ isReconnecting,
523
517
  connectingElapsed,
524
- thinkingWarningThreshold,
525
518
  pendingApproval,
526
519
  contextWindowPct,
527
520
  sessionTitle,