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) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-02 10:48:24 +00:00
parent e7df7e5599
commit d6f5e595d9
3 changed files with 34 additions and 2 deletions

View file

@ -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) {

View file

@ -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 });

View file

@ -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 (
<div className="fixed inset-0 z-40 flex h-[100dvh] flex-col bg-background">
<OfflineBanner queuedCount={queuedCount} />
<InstallPromptBanner />
{/* pb-16 accounts for the existing MobileBottomNav (h-16) at the bottom of Layout */}
<div className="flex-1 overflow-hidden pb-16">
{selectedCompanyId ? (
@ -218,6 +240,8 @@ export function MobileChatView() {
// Active conversation view
return (
<div className="fixed inset-0 z-40 flex h-[100dvh] flex-col bg-background">
<OfflineBanner queuedCount={queuedCount} />
<NotificationPermissionPrompt agentResponseCount={agentResponseCount} />
{/* Header: 48px tall */}
<div className="flex h-12 flex-shrink-0 items-center gap-2 border-b border-border px-3">
<Button