Skip to content

feat: extract @hypr/helpchat shared package for Chatwoot integration#4022

Closed
devin-ai-integration[bot] wants to merge 3 commits intomainfrom
devin/1771298581-helpchat-shared-package
Closed

feat: extract @hypr/helpchat shared package for Chatwoot integration#4022
devin-ai-integration[bot] wants to merge 3 commits intomainfrom
devin/1771298581-helpchat-shared-package

Conversation

@devin-ai-integration
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot commented Feb 17, 2026

feat: extract @hypr/helpchat shared package for Chatwoot integration

Summary

Extracts Chatwoot integration logic from desktop-specific hooks into a new platform-agnostic @hypr/helpchat package under packages/helpchat/. This package can be shared between apps/desktop and apps/web (for the future /app/help route). The desktop hooks become thin wrappers that inject Tauri-specific config (custom fetch, auth).

New package (packages/helpchat/):

  • types.ts – shared type definitions (ChatwootContact, HelpChatConfig, AgentMessage, etc.)
  • client.ts – API client functions wrapping @hypr/api-client for contacts, conversations, and messages
  • events.ts – SSE event stream handler for real-time human agent messages
  • hooks.ts – React hooks: useChatwootContact, useConversation, useAgentEvents, useHelpChat (composite)

Desktop changes:

  • useChatwootPersistence.ts → thin wrapper injecting Tauri fetch + auth config into useHelpChat
  • useChatwootEvents.ts → re-exports from shared package
  • tab-content.tsx → wires persistence into support chat tab to persist user/agent messages to Chatwoot

Design notes:

  • Platform-agnostic: accepts optional fetchFn in config to support both browser fetch and Tauri's custom HTTP plugin
  • Auto-resume: useConversation can automatically resume the latest conversation on mount
  • Composable hooks: useHelpChat combines contact, conversation, and event stream management

Review & Testing Checklist for Human

⚠️ Risk Level: YELLOW – Core logic refactored without runtime testing; API signature changes could break other consumers.

  • Verify no other consumers of useChatwootPersistence or useChatwootEvents exist – The return type and API changed. Search codebase for other imports.
  • Test end-to-end persistence flow – Open support chat in desktop app, send messages, verify they appear in Chatwoot dashboard with correct message types (user=incoming, agent=outgoing).
  • Test conversation auto-resume – Close and reopen support chat, verify conversation resumes from Chatwoot history.
  • Review message persistence logic in tab-content.tsx:192-220 – Uses lastPersistedCountRef to track which messages have been persisted. Check for edge cases: messages sent during streaming, rapid status changes, or if messages array is ever replaced (not appended).
  • Test user switching – The initRef and resumeAttemptedRef guards in hooks prevent re-initialization if userId changes. Verify this doesn't break logout/login flows.
  • Test human agent responses – Have a human agent respond via Chatwoot dashboard, verify message appears in desktop chat UI (currently not wired up – onHumanAgentMessage callback is passed but not used in tab-content.tsx).

Test Plan

  1. Run desktop app, open support chat tab
  2. Send a few messages, verify they persist to Chatwoot
  3. Close and reopen chat, verify conversation resumes
  4. Have human agent respond via Chatwoot, verify message appears in chat (if wired up)
  5. Check Chatwoot dashboard to confirm message types are correct
  6. Test logout/login with different user, verify new conversation starts

Notes

  • TypeScript checks pass, but no runtime testing was performed
  • The onHumanAgentMessage callback is plumbed through but not actually used in tab-content.tsx – human agent messages won't appear in the UI yet
  • React peer dependency is pinned to ^19.2.3 – may need to broaden if web app uses different version
  • Message persistence is fire-and-forget (.catch(console.error)) – failures are logged but not surfaced to user
  • SSE connection is established even when onHumanAgentMessage is not provided (noop handler) – could be optimized

Updates since last revision

  • Fixed import ordering in events.ts to pass dprint formatting checks
  • All CI checks now passing (fmt, desktop_ci for linux/macos/windows)

Link to Devin run: https://app.devin.ai/sessions/02779f6a024140b49910f75592007a76
Requested by: @yujonglee


Open with Devin

- Create packages/helpchat with types, client, events, and hooks
- Refactor desktop useChatwootPersistence to use shared useHelpChat hook
- Simplify useChatwootEvents to re-export from shared package
- Wire Chatwoot persistence into support chat tab-content.tsx
- Platform-agnostic design with custom fetchFn for Tauri/browser support

Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
@devin-ai-integration
Copy link
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@netlify
Copy link

netlify bot commented Feb 17, 2026

Deploy Preview for hyprnote-storybook canceled.

Name Link
🔨 Latest commit e42b47d
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote-storybook/deploys/6993e451c76be600080e2fd8

@netlify
Copy link

netlify bot commented Feb 17, 2026

Deploy Preview for hyprnote failed.

Name Link
🔨 Latest commit e42b47d
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote/deploys/6993e451d508080007a6e01b

Comment on lines 198 to 215
lastPersistedCountRef.current = messages.length;

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);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Race condition: Messages can be silently lost on persistence failure

The lastPersistedCountRef.current is updated synchronously (line 198) before the async persistUserMessage and persistAgentMessage operations complete. If any persist operation fails, those messages will never be retried because the ref already advanced past them.

// Current problematic code:
lastPersistedCountRef.current = messages.length; // Updated immediately

for (const msg of newMessages) {
  if (msg.role === "user") {
    chatwoot.persistUserMessage(textContent).catch(console.error); // Fails silently
  }
}

Fix: Only update the ref after successful persistence, or track failed messages:

const persistPromises: Promise<void>[] = [];

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") {
    persistPromises.push(chatwoot.persistUserMessage(textContent));
  } else if (msg.role === "assistant") {
    persistPromises.push(chatwoot.persistAgentMessage(textContent));
  }
}

Promise.all(persistPromises)
  .then(() => {
    lastPersistedCountRef.current = messages.length;
  })
  .catch(console.error);
Suggested change
lastPersistedCountRef.current = messages.length;
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);
}
}
const persistPromises: Promise<void>[] = [];
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") {
persistPromises.push(chatwoot.persistUserMessage(textContent));
} else if (msg.role === "assistant") {
persistPromises.push(chatwoot.persistAgentMessage(textContent));
}
}
Promise.all(persistPromises)
.then(() => {
lastPersistedCountRef.current = messages.length;
})
.catch(console.error);

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Contributor Author

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +18 to +24
function makeClient(config: HelpChatConfig) {
const headers: Record<string, string> = {};
if (config.accessToken) {
headers.Authorization = `Bearer ${config.accessToken}`;
}
return createClient({ baseUrl: config.apiBaseUrl, headers });
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

🟡 makeClient ignores config.fetchFn, so custom fetch (e.g. tauriFetch) is only used for SSE, not API calls

The HelpChatConfig type defines a fetchFn option specifically for platform-agnostic HTTP support, and the desktop app passes tauriFetch as fetchFn at apps/desktop/src/hooks/useChatwootPersistence.ts:23. The events.ts:19 correctly uses config.fetchFn ?? globalThis.fetch for the SSE stream. However, makeClient in client.ts does not forward config.fetchFn to createClient, despite createClient supporting a fetch option (see packages/api-client/src/generated/client/types.gen.ts:25 and packages/api-client/src/generated/client/client.gen.ts:38).

Root Cause and Impact

All API functions in client.ts (createOrFindContact, fetchConversations, createNewConversation, fetchMessages, persistMessage) call makeClient(config) which constructs the client as:

return createClient({ baseUrl: config.apiBaseUrl, headers });

This always falls back to globalThis.fetch for API calls, silently ignoring the fetchFn the caller explicitly provided. Meanwhile, events.ts:19 correctly does:

const fetchFn: FetchFn = config.fetchFn ?? globalThis.fetch;

This inconsistency partially defeats the purpose of the fetchFn config. The PR description states the design goal is "Platform-agnostic: accepts optional fetchFn in config to support both browser fetch and Tauri's custom HTTP plugin", but this only holds for the SSE stream, not the REST API calls. Any future consumer on a platform where globalThis.fetch is unavailable or behaves differently would silently get broken API calls while SSE works correctly.

Suggested change
function makeClient(config: HelpChatConfig) {
const headers: Record<string, string> = {};
if (config.accessToken) {
headers.Authorization = `Bearer ${config.accessToken}`;
}
return createClient({ baseUrl: config.apiBaseUrl, headers });
}
function makeClient(config: HelpChatConfig) {
const headers: Record<string, string> = {};
if (config.accessToken) {
headers.Authorization = `Bearer ${config.accessToken}`;
}
return createClient({ baseUrl: config.apiBaseUrl, headers, fetch: config.fetchFn });
}
Open in Devin Review

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

Comment on lines +248 to +255
const noopHandler = useCallback(() => {}, []);

useAgentEvents({
config,
pubsubToken: contact?.pubsubToken ?? null,
conversationId: conversation.conversationId,
onAgentMessage: onHumanAgentMessage ?? noopHandler,
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

🚩 SSE connection is always established even when no handler is wired up

In packages/helpchat/src/hooks.ts:248-254, useHelpChat always calls useAgentEvents, passing a noopHandler when onHumanAgentMessage is undefined. In the current desktop usage (tab-content.tsx:61-64), onHumanAgentMessage is never provided, so a real SSE connection is established to the server on every support chat open, but all received messages are silently discarded by the noop. The PR description acknowledges this (onHumanAgentMessage callback is plumbed through but not used), but the unnecessary network connection is worth noting — consider conditionally connecting only when a handler is provided.

Open in Devin Review

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

devin-ai-integration bot and others added 2 commits February 17, 2026 03:38
Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
Copy link
Contributor Author

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 7 additional findings in Devin Review.

Open in Devin Review

Comment on lines +200 to +206

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

lastPersistedCountRef.current = messages.length;
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.

Comment on lines +18 to +24
function makeClient(config: HelpChatConfig) {
const headers: Record<string, string> = {};
if (config.accessToken) {
headers.Authorization = `Bearer ${config.accessToken}`;
}
return createClient({ baseUrl: config.apiBaseUrl, headers });
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

🚩 fetchFn is only used for SSE streaming, not for API client calls

The HelpChatConfig.fetchFn is accepted and passed through to connectEventStream in packages/helpchat/src/events.ts:19, but makeClient at packages/helpchat/src/client.ts:18-24 does not forward config.fetchFn to createClient(). The @hypr/api-client client supports a fetch option (packages/api-client/src/generated/client/client.gen.ts:38), so it would be straightforward to pass it.

This is not a regression — the old useChatwootPersistence also used the default createClient (with globalThis.fetch) for API calls and only used tauriFetch for the SSE stream. However, the PR description states the package is "platform-agnostic" and accepts fetchFn to support both browser fetch and Tauri's HTTP plugin. A future consumer might reasonably expect fetchFn to apply to all HTTP calls, not just SSE. Consider either passing fetchFn through to makeClient for consistency, or documenting that it only affects the event stream.

Open in Devin Review

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments