From 4b9d267f46a916ce370ecd4011404c2b33ff9086 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Thu, 2 Apr 2026 10:48:24 +0000 Subject: [PATCH] fix(v1.3): close 3 integration gaps from milestone audit 1. Push notifications: call sendPushToAll after streaming completes 2. Mobile offline: add useOfflineQueue + banners to MobileChatView 3. New conversation streaming: call startStream in Path 1 handleSend Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/routes/chat.ts | 9 +++++++++ ui/src/components/ChatPanel.tsx | 3 +-- ui/src/components/MobileChatView.tsx | 24 ++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/server/src/routes/chat.ts b/server/src/routes/chat.ts index f4d38517..97748a94 100644 --- a/server/src/routes/chat.ts +++ b/server/src/routes/chat.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import type { Db } from "@paperclipai/db"; import { assertBoard, assertCompanyAccess } from "./authz.js"; import { chatService } from "../services/chat.js"; +import { sendPushToAll } from "../services/pushService.js"; import { issueService } from "../services/issues.js"; import { z } from "zod"; import { @@ -117,6 +118,14 @@ export function chatRoutes(db: Db): Router { agentId: agentId || undefined, }); res.write(`data: ${JSON.stringify({ done: true, messageId: message.id, content: fullContent.trim() })}\n\n`); + + // Fire push notification for offline subscribers (PWA-06) + const conversation = await svc.getConversation(req.params.id!); + sendPushToAll(db, conversation.companyId, { + title: "New agent response", + body: fullContent.trim().slice(0, 100), + data: { url: `/chat/${conversation.id}` }, + }).catch(() => {}); // non-blocking } } catch (err) { if (res.writable && !abort.signal.aborted) { diff --git a/ui/src/components/ChatPanel.tsx b/ui/src/components/ChatPanel.tsx index b60076aa..74b23d3a 100644 --- a/ui/src/components/ChatPanel.tsx +++ b/ui/src/components/ChatPanel.tsx @@ -178,8 +178,7 @@ export function ChatPanel() { queryClient.invalidateQueries({ queryKey: ["chat", "messages", newConvo.id] }); } queryClient.invalidateQueries({ queryKey: ["chat"] }); - // Note: streaming starts on next render when activeConversationId is set - // For now, the echo stream will be triggered by the new conversation + startStream(content, resolvedAgentId ?? undefined); } else { // Path 2: Active conversation -- post user message then stream const message = await chatApi.postMessage(activeConversationId, { role: "user", content }); diff --git a/ui/src/components/MobileChatView.tsx b/ui/src/components/MobileChatView.tsx index bbe491bb..0520ef57 100644 --- a/ui/src/components/MobileChatView.tsx +++ b/ui/src/components/MobileChatView.tsx @@ -9,6 +9,8 @@ import { useStreamingChat } from "../hooks/useStreamingChat"; import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault"; import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks"; import { useChatFileUpload } from "../hooks/useChatFileUpload"; +import { useOfflineQueue } from "../hooks/useOfflineQueue"; +import { useOnlineStatus } from "../hooks/useOnlineStatus"; import { useChatConversations } from "../hooks/useChatConversations"; import { useQuery } from "@tanstack/react-query"; import { chatApi } from "../api/chat"; @@ -20,6 +22,9 @@ import { ChatAgentSelector } from "./ChatAgentSelector"; import { ChatConversationList } from "./ChatConversationList"; import { ChatStopButton } from "./ChatStopButton"; import { PullToRefresh } from "./PullToRefresh"; +import { OfflineBanner } from "./OfflineBanner"; +import { InstallPromptBanner } from "./InstallPromptBanner"; +import { NotificationPermissionPrompt } from "./NotificationPermissionPrompt"; import { Button } from "@/components/ui/button"; import type { AgentRole } from "@paperclipai/shared"; @@ -41,6 +46,8 @@ export function MobileChatView() { const { messages } = useChatMessages(activeConversationId); const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId); const { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds } = useChatFileUpload(activeConversationId); + const { enqueue, queuedCount } = useOfflineQueue(); + const isOnline = useOnlineStatus(); const brainstormerDefaultId = useBrainstormerDefault(); // useChatConversations for refetch (pull-to-refresh) @@ -103,8 +110,20 @@ export function MobileChatView() { [activeConversationId, queryClient, pushToast], ); + const [agentResponseCount, setAgentResponseCount] = useState(0); + const handleSend = async (content: string) => { if (!selectedCompanyId) return; + + // If offline, enqueue the message and show a toast + if (!isOnline) { + if (activeConversationId) { + await enqueue(activeConversationId, content); + pushToast({ title: "Message queued — will send when you're back online", tone: "info" }); + } + return; + } + const resolvedAgentId = resolveAgentFromContent(content, agents, effectiveBrainstormerId); const fileIdsToAttach = [...completedFileIds]; @@ -122,6 +141,7 @@ export function MobileChatView() { queryClient.invalidateQueries({ queryKey: ["chat", "messages", newConvo.id] }); } queryClient.invalidateQueries({ queryKey: ["chat"] }); + startStream(content, resolvedAgentId ?? undefined); } else { const message = await chatApi.postMessage(activeConversationId, { role: "user", content }); if (fileIdsToAttach.length > 0) { @@ -199,6 +219,8 @@ export function MobileChatView() { if (!activeConversationId) { return (
+ + {/* pb-16 accounts for the existing MobileBottomNav (h-16) at the bottom of Layout */}
{selectedCompanyId ? ( @@ -218,6 +240,8 @@ export function MobileChatView() { // Active conversation view return (
+ + {/* Header: 48px tall */}