| .. | .. |
|---|
| 116 | 116 | |
|---|
| 117 | 117 | // --- Context --- |
|---|
| 118 | 118 | |
|---|
| 119 | +interface IncomingToast { |
|---|
| 120 | + sessionId: string; |
|---|
| 121 | + sessionName: string; |
|---|
| 122 | + preview: string; |
|---|
| 123 | +} |
|---|
| 124 | + |
|---|
| 119 | 125 | interface ChatContextValue { |
|---|
| 120 | 126 | messages: Message[]; |
|---|
| 121 | 127 | sendTextMessage: (text: string) => void; |
|---|
| .. | .. |
|---|
| 136 | 142 | loadMoreMessages: () => void; |
|---|
| 137 | 143 | hasMoreMessages: boolean; |
|---|
| 138 | 144 | unreadCounts: Record<string, number>; |
|---|
| 145 | + incomingToast: IncomingToast | null; |
|---|
| 146 | + dismissToast: () => void; |
|---|
| 139 | 147 | latestScreenshot: string | null; |
|---|
| 140 | 148 | requestScreenshot: () => void; |
|---|
| 141 | 149 | sendNavKey: (key: string) => void; |
|---|
| .. | .. |
|---|
| 156 | 164 | const [messages, setMessages] = useState<Message[]>([]); |
|---|
| 157 | 165 | // Unread counts for non-active sessions |
|---|
| 158 | 166 | const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({}); |
|---|
| 159 | | - // Typing indicator from server |
|---|
| 167 | + // Per-session typing indicator (sessionId → boolean) |
|---|
| 168 | + const typingMapRef = useRef<Record<string, boolean>>({}); |
|---|
| 160 | 169 | const [isTyping, setIsTyping] = useState(false); |
|---|
| 170 | + // Toast for other-session incoming messages |
|---|
| 171 | + const [incomingToast, setIncomingToast] = useState<{ sessionId: string; sessionName: string; preview: string } | null>(null); |
|---|
| 161 | 172 | // PAI projects list |
|---|
| 162 | 173 | const [projects, setProjects] = useState<PaiProject[]>([]); |
|---|
| 163 | 174 | // Pagination: does the active session have more messages in storage? |
|---|
| .. | .. |
|---|
| 199 | 210 | delete next[active.id]; |
|---|
| 200 | 211 | return next; |
|---|
| 201 | 212 | }); |
|---|
| 213 | + // Sync typing indicator for the new active session |
|---|
| 214 | + const activeTyping = typingMapRef.current[active.id] ?? false; |
|---|
| 215 | + setIsTyping(activeTyping); |
|---|
| 202 | 216 | } |
|---|
| 203 | 217 | activeSessionIdRef.current = active.id; |
|---|
| 204 | 218 | return active.id; |
|---|
| .. | .. |
|---|
| 251 | 265 | ...u, |
|---|
| 252 | 266 | [sessionId]: (u[sessionId] ?? 0) + 1, |
|---|
| 253 | 267 | })); |
|---|
| 268 | + // Show toast for other-session messages (assistant only, skip system noise) |
|---|
| 269 | + if (msg.role === "assistant") { |
|---|
| 270 | + setSessions((prev) => { |
|---|
| 271 | + const session = prev.find((s) => s.id === sessionId); |
|---|
| 272 | + const name = session?.name ?? sessionId.slice(0, 8); |
|---|
| 273 | + const preview = msg.type === "voice" ? "🎤 Voice note" : msg.type === "image" ? "📷 Image" : (msg.content ?? "").slice(0, 60); |
|---|
| 274 | + setIncomingToast({ sessionId, sessionName: name, preview }); |
|---|
| 275 | + return prev; |
|---|
| 276 | + }); |
|---|
| 277 | + } |
|---|
| 254 | 278 | } |
|---|
| 255 | 279 | }, []); |
|---|
| 256 | 280 | |
|---|
| .. | .. |
|---|
| 359 | 383 | break; |
|---|
| 360 | 384 | } |
|---|
| 361 | 385 | case "session_switched": { |
|---|
| 362 | | - const msg: Message = { |
|---|
| 363 | | - id: generateId(), |
|---|
| 364 | | - role: "system", |
|---|
| 365 | | - type: "text", |
|---|
| 366 | | - content: `Switched to ${data.name}`, |
|---|
| 367 | | - timestamp: Date.now(), |
|---|
| 368 | | - }; |
|---|
| 369 | | - addMessageToActive(msg); |
|---|
| 386 | + // Just refresh session list — no system message needed |
|---|
| 370 | 387 | sendCommand("sessions"); |
|---|
| 371 | 388 | break; |
|---|
| 372 | 389 | } |
|---|
| 373 | 390 | case "session_renamed": { |
|---|
| 374 | | - const msg: Message = { |
|---|
| 375 | | - id: generateId(), |
|---|
| 376 | | - role: "system", |
|---|
| 377 | | - type: "text", |
|---|
| 378 | | - content: `Renamed to ${data.name}`, |
|---|
| 379 | | - timestamp: Date.now(), |
|---|
| 380 | | - }; |
|---|
| 381 | | - addMessageToActive(msg); |
|---|
| 391 | + // Just refresh session list — no system message needed |
|---|
| 382 | 392 | sendCommand("sessions"); |
|---|
| 383 | 393 | break; |
|---|
| 384 | 394 | } |
|---|
| .. | .. |
|---|
| 388 | 398 | break; |
|---|
| 389 | 399 | } |
|---|
| 390 | 400 | case "typing": { |
|---|
| 391 | | - setIsTyping(data.typing); |
|---|
| 401 | + const typingSession = (data.sessionId as string) || activeSessionIdRef.current || "_global"; |
|---|
| 402 | + typingMapRef.current[typingSession] = !!data.typing; |
|---|
| 403 | + // Only show typing indicator if it's for the active session |
|---|
| 404 | + const activeTyping = typingMapRef.current[activeSessionIdRef.current ?? ""] ?? false; |
|---|
| 405 | + setIsTyping(activeTyping); |
|---|
| 392 | 406 | break; |
|---|
| 393 | 407 | } |
|---|
| 394 | 408 | case "status": { |
|---|
| .. | .. |
|---|
| 545 | 559 | sendCommand("projects"); |
|---|
| 546 | 560 | }, [sendCommand]); |
|---|
| 547 | 561 | |
|---|
| 562 | + const dismissToast = useCallback(() => { |
|---|
| 563 | + setIncomingToast(null); |
|---|
| 564 | + }, []); |
|---|
| 565 | + |
|---|
| 548 | 566 | const loadMoreMessages = useCallback(() => { |
|---|
| 549 | 567 | const sessId = activeSessionIdRef.current; |
|---|
| 550 | 568 | if (!sessId) return; |
|---|
| .. | .. |
|---|
| 595 | 613 | loadMoreMessages, |
|---|
| 596 | 614 | hasMoreMessages, |
|---|
| 597 | 615 | unreadCounts, |
|---|
| 616 | + incomingToast, |
|---|
| 617 | + dismissToast, |
|---|
| 598 | 618 | latestScreenshot, |
|---|
| 599 | 619 | requestScreenshot, |
|---|
| 600 | 620 | sendNavKey, |
|---|