Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@effect/schema": "^0.75.5",
"@floating-ui/react": "^0.27.17",
"@hypr/api-client": "workspace:*",
"@hypr/helpchat": "workspace:*",
"@hypr/codemirror": "workspace:^",
"@hypr/plugin-analytics": "workspace:*",
"@hypr/plugin-apple-calendar": "workspace:*",
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/src/components/main/body/chat/tab-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ContextEntity } from "../../../../chat/context-item";
import { composeContextEntities } from "../../../../chat/context/composer";
import type { HyprUIMessage } from "../../../../chat/types";
import { ElicitationProvider } from "../../../../contexts/elicitation";
import { useChatwootPersistence } from "../../../../hooks/useChatwootPersistence";
import { useFeedbackLanguageModel } from "../../../../hooks/useLLMConnection";
import { useSupportMCP } from "../../../../hooks/useSupportMCP";
import type { Tab } from "../../../../store/zustand/tabs";
Expand Down Expand Up @@ -43,6 +44,8 @@ function SupportChatTabView({
(state) => state.updateChatSupportTabState,
);
const { session } = useAuth();
const userId = session?.user?.id;
const userEmail = session?.user?.email;

const stableSessionId = useStableSessionId(groupId);
const feedbackModel = useFeedbackLanguageModel();
Expand All @@ -55,6 +58,11 @@ function SupportChatTabView({
isReady,
} = useSupportMCP(true, session?.access_token);

const chatwoot = useChatwootPersistence(userId, {
email: userEmail,
name: userEmail,
});

const mcpToolCount = Object.keys(mcpTools).length;

const onGroupCreated = useCallback(
Expand Down Expand Up @@ -105,6 +113,7 @@ function SupportChatTabView({
supportContextEntities={supportContextEntities}
pendingElicitation={pendingElicitation}
respondToElicitation={respondToElicitation}
chatwoot={chatwoot}
/>
)}
</ChatSession>
Expand All @@ -121,6 +130,7 @@ function SupportChatTabInner({
supportContextEntities,
pendingElicitation,
respondToElicitation,
chatwoot,
}: {
tab: Extract<Tab, { type: "chat_support" }>;
sessionProps: {
Expand Down Expand Up @@ -151,6 +161,7 @@ function SupportChatTabInner({
supportContextEntities: ContextEntity[];
pendingElicitation?: { message: string } | null;
respondToElicitation?: (approved: boolean) => void;
chatwoot: ReturnType<typeof useChatwootPersistence>;
}) {
const {
messages,
Expand All @@ -164,6 +175,61 @@ function SupportChatTabInner({
isSystemPromptReady,
} = sessionProps;
const sentRef = useRef(false);
const chatwootConvStartedRef = useRef(false);
const lastPersistedCountRef = useRef(0);

useEffect(() => {
if (
chatwoot.isReady &&
!chatwoot.conversationId &&
!chatwootConvStartedRef.current
) {
chatwootConvStartedRef.current = true;
chatwoot.startConversation();
}
}, [chatwoot.isReady, chatwoot.conversationId, chatwoot.startConversation]);

useEffect(() => {
if (
!chatwoot.conversationId ||
status === "streaming" ||
status === "submitted"
) {
return;
}

const newMessages = messages.slice(lastPersistedCountRef.current);
if (newMessages.length === 0) {
return;
}

lastPersistedCountRef.current = messages.length;
Comment on lines +200 to +206
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 lastPersistedCountRef skips persisting regenerated assistant messages

When a user triggers regenerate (wired to onReload in ChatBody), the last assistant message is removed from the messages array and a new streaming response begins. However, lastPersistedCountRef still holds the old higher count, so the regenerated message is never persisted to Chatwoot.

Root Cause and Walkthrough

Consider the following sequence:

  1. messages = [user1, asst1, user2, asst2]lastPersistedCountRef.current = 4
  2. User clicks regenerate → asst2 is removed → messages = [user1, asst1, user2] (length 3)
  3. Status becomes "streaming" → the effect at line 192 returns early
  4. Streaming completes → messages = [user1, asst1, user2, asst2_new] (length 4), status = "ready"
  5. Effect runs: newMessages = messages.slice(4)[] → returns early at line 202
  6. lastPersistedCountRef is never updated (stays at 4), and asst2_new is never persisted to Chatwoot

The ref-based counter assumes messages only grow monotonically. Any operation that shrinks or replaces the array (like regenerate) permanently desynchronizes the counter. Since messages.slice(N) returns [] when N >= messages.length, all subsequent messages at indices ≤ the old count are silently dropped.

Impact: After any message regeneration, the Chatwoot dashboard will be missing the regenerated assistant response, giving support agents an incomplete view of the conversation.

Prompt for agents
In apps/desktop/src/components/main/body/chat/tab-content.tsx, the useEffect at lines 192-232 uses lastPersistedCountRef as a simple monotonic counter to track which messages have been persisted. This breaks when messages are regenerated (array shrinks then grows). Instead of tracking by count/index, track by message IDs. Replace lastPersistedCountRef with a Set<string> (e.g., persistedMessageIdsRef) that stores the IDs of already-persisted messages. In the effect body, filter newMessages as messages that are not in the set, and after persisting each message, add its ID to the set. This approach is resilient to array reordering, shrinking, or replacement.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


for (const msg of newMessages) {
const textContent = msg.parts
.filter(
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
)
.map((p) => p.text)
.join("");

if (!textContent) {
continue;
}

if (msg.role === "user") {
chatwoot.persistUserMessage(textContent).catch(console.error);
} else if (msg.role === "assistant") {
chatwoot.persistAgentMessage(textContent).catch(console.error);
}
}
}, [
messages,
status,
chatwoot.conversationId,
chatwoot.persistUserMessage,
chatwoot.persistAgentMessage,
]);

useEffect(() => {
const initialMessage = tab.state.initialMessage;
Expand Down
93 changes: 2 additions & 91 deletions apps/desktop/src/hooks/useChatwootEvents.ts
Original file line number Diff line number Diff line change
@@ -1,91 +1,2 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { useEffect, useRef } from "react";

import { createClient } from "@hypr/api-client/client";

import { useAuth } from "../auth";
import { env } from "../env";

export function useChatwootEvents({
pubsubToken,
conversationId,
onAgentMessage,
}: {
pubsubToken: string | null;
conversationId: number | null;
onAgentMessage: (content: string, senderName: string) => void;
}) {
const { session } = useAuth();
const onAgentMessageRef = useRef(onAgentMessage);
onAgentMessageRef.current = onAgentMessage;

useEffect(() => {
if (!pubsubToken || conversationId == null || !session?.access_token) {
return;
}

const abortController = new AbortController();

const client = createClient({ baseUrl: env.VITE_API_URL });
const url = client.buildUrl({
url: "/support/chatwoot/conversations/{conversation_id}/events",
path: { conversation_id: conversationId },
query: { pubsub_token: pubsubToken },
});

(async () => {
try {
const response = await tauriFetch(url, {
method: "GET",
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${session.access_token}`,
},
signal: abortController.signal,
});

if (!response.ok || !response.body) {
return;
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";

while (true) {
const { done, value } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() ?? "";

for (const part of parts) {
const dataLine = part
.split("\n")
.find((line) => line.startsWith("data: "));
if (!dataLine) continue;

try {
const payload = JSON.parse(dataLine.slice(6));
if (payload.content) {
onAgentMessageRef.current(
payload.content,
payload.senderName ?? "Agent",
);
}
} catch {}
}
}
} catch (e) {
if (!abortController.signal.aborted) {
console.error("Chatwoot events stream error:", e);
}
}
})();

return () => {
abortController.abort();
};
}, [pubsubToken, conversationId, session?.access_token]);
}
export { useAgentEvents } from "@hypr/helpchat";
export type { AgentMessage } from "@hypr/helpchat";
116 changes: 21 additions & 95 deletions apps/desktop/src/hooks/useChatwootPersistence.ts
Original file line number Diff line number Diff line change
@@ -1,109 +1,35 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { useMemo } from "react";

import {
createContact,
createConversation,
sendMessage,
} from "@hypr/api-client";
import { createClient } from "@hypr/api-client/client";
import type { AgentMessage, ContactInfo, HelpChatConfig } from "@hypr/helpchat";
import { useHelpChat } from "@hypr/helpchat";

import { useAuth } from "../auth";
import { env } from "../env";

function makeClient(accessToken?: string | null) {
const headers: Record<string, string> = {};
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
return createClient({ baseUrl: env.VITE_API_URL, headers });
}
export type { AgentMessage } from "@hypr/helpchat";

export function useChatwootPersistence(
userId: string | undefined,
contactInfo?: {
email?: string;
name?: string;
customAttributes?: Record<string, unknown>;
},
contactInfo?: ContactInfo,
onHumanAgentMessage?: (message: AgentMessage) => void,
) {
const { session } = useAuth();
const [sourceId, setSourceId] = useState<string | null>(null);
const [pubsubToken, setPubsubToken] = useState<string | null>(null);
const [conversationId, setConversationId] = useState<number | null>(null);
const conversationIdRef = useRef<number | null>(null);
const initRef = useRef(false);

useEffect(() => {
if (!userId || initRef.current) {
return;
}
initRef.current = true;

const client = makeClient(session?.access_token);

createContact({
client,
body: {
identifier: userId,
email: contactInfo?.email,
name: contactInfo?.name,
customAttributes: contactInfo?.customAttributes,
},
}).then(({ data }) => {
if (data) {
setSourceId(data.sourceId);
setPubsubToken(data.pubsubToken);
}
});
}, [userId, session?.access_token]);

const startConversation = useCallback(async () => {
if (!sourceId) {
return null;
}

const client = makeClient(session?.access_token);
const { data } = await createConversation({
client,
body: { sourceId },
});

if (data) {
const convId = data.conversationId;
conversationIdRef.current = convId;
setConversationId(convId);
return convId;
}
return null;
}, [sourceId, session?.access_token]);

const persistMessage = useCallback(
async (content: string, messageType: "incoming" | "outgoing") => {
const convId = conversationIdRef.current;
if (convId == null || !sourceId) {
return;
}

const client = makeClient(session?.access_token);
await sendMessage({
client,
path: { conversation_id: convId },
body: {
content,
messageType,
sourceId,
},
});
},
[sourceId, session?.access_token],
const config: HelpChatConfig = useMemo(
() => ({
apiBaseUrl: env.VITE_API_URL,
accessToken: session?.access_token,
fetchFn: tauriFetch as HelpChatConfig["fetchFn"],
}),
[session?.access_token],
);

return {
sourceId,
pubsubToken,
conversationId,
startConversation,
persistMessage,
isReady: !!sourceId,
};
return useHelpChat({
config,
userId,
contactInfo,
autoResume: true,
onHumanAgentMessage,
});
}
15 changes: 15 additions & 0 deletions packages/helpchat/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "@hypr/helpchat",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@hypr/api-client": "workspace:*"
},
"peerDependencies": {
"react": "^19.2.3"
},
"devDependencies": {
"@types/react": "^19.2.13"
}
}
Loading
Loading