Skip to content
Merged
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Use **PNPM** commands in this repo (workspace uses `pnpm-workspace.yaml`).
- Always run new/updated tests after creating or changing them.
- Prefer focused verification first (targeted package/spec), then broader checks when needed.
- At the end of each proposal when ready for a PR, run `pnpm ci:check` to ensure all checks pass.

## Quick Repo Orientation

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Goal: ship a professional open-source customer messaging platform with strong de
- [p] a CI AI agent to check for any doc drift and update docs based on the latest code
- [ ] convert supportAttachments.finalizeUpload into an action + internal mutation pipeline so we can add real signature checks too. The current finalizeUpload boundary is a Convex mutation and ctx.storage.get() is only available in actions. Doing true magic-byte validation would need a larger refactor of that finalize flow.
- [ ] add URL param deep links for the widget - Go to a url like ?open-widget-tab=home to open the widget to that tab, etc.

- [ ] make web admin chat input field multi line, with scrollbar when needed (currently single line max)

apps/web/src/app/outbound/[id]/OutboundTriggerPanel.tsx
Comment on lines +67 to 71
Expand Down
49 changes: 38 additions & 11 deletions apps/web/src/app/inbox/hooks/useInboxConvex.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useEffect, useState } from "react";
import type { Id } from "@opencom/convex/dataModel";
import type { SupportAttachmentFinalizeResult } from "@opencom/web-shared";
import {
Expand Down Expand Up @@ -144,8 +145,8 @@ const GET_SUGGESTIONS_FOR_CONVERSATION_REF = webActionRef<
const CREATE_SNIPPET_REF = webMutationRef<CreateSnippetArgs, Id<"snippets">>("snippets:create");
const UPDATE_SNIPPET_REF = webMutationRef<UpdateSnippetArgs, unknown>("snippets:update");
const SNIPPETS_LIST_QUERY_REF = webQueryRef<WorkspaceArgs, InboxSnippet[]>("snippets:list");
const KNOWLEDGE_SEARCH_QUERY_REF = webQueryRef<KnowledgeSearchArgs, InboxKnowledgeItem[]>(
"knowledge:search"
const KNOWLEDGE_SEARCH_ACTION_REF = webActionRef<KnowledgeSearchArgs, InboxKnowledgeItem[]>(
"knowledge:searchWithEmbeddings"
);
const RECENTLY_USED_KNOWLEDGE_QUERY_REF = webQueryRef<
RecentlyUsedKnowledgeArgs,
Expand Down Expand Up @@ -177,6 +178,39 @@ export function useInboxConvex({
}
: "skip";

const searchKnowledge = useWebAction(KNOWLEDGE_SEARCH_ACTION_REF);
const [knowledgeResults, setKnowledgeResults] = useState<InboxKnowledgeItem[] | undefined>(
undefined
);

useEffect(() => {
if (!workspaceId || knowledgeSearch.trim().length < 1) {
setKnowledgeResults(undefined);
return;
}

let cancelled = false;
const timeoutId = setTimeout(() => {
searchKnowledge({ workspaceId, query: knowledgeSearch, limit: 20 })
.then((results) => {
if (!cancelled) {
setKnowledgeResults(results);
}
})
.catch((error) => {
console.error("Knowledge search failed:", error);
if (!cancelled) {
setKnowledgeResults(undefined);
}
});
}, 300);

return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [workspaceId, knowledgeSearch, searchKnowledge]);

return {
aiResponses: useWebQuery(
AI_CONVERSATION_RESPONSES_QUERY_REF,
Expand All @@ -188,16 +222,9 @@ export function useInboxConvex({
createSnippet: useWebMutation(CREATE_SNIPPET_REF),
convertToTicket: useWebMutation(CONVERT_CONVERSATION_TO_TICKET_REF),
finalizeSupportAttachmentUpload: useWebMutation(FINALIZE_SUPPORT_ATTACHMENT_UPLOAD_REF),
generateSupportAttachmentUploadUrl: useWebMutation(
GENERATE_SUPPORT_ATTACHMENT_UPLOAD_URL_REF
),
generateSupportAttachmentUploadUrl: useWebMutation(GENERATE_SUPPORT_ATTACHMENT_UPLOAD_URL_REF),
getSuggestionsForConversation: useWebAction(GET_SUGGESTIONS_FOR_CONVERSATION_REF),
knowledgeResults: useWebQuery(
KNOWLEDGE_SEARCH_QUERY_REF,
workspaceId && knowledgeSearch.trim().length >= 1
? { workspaceId, query: knowledgeSearch, limit: 20 }
: "skip"
),
knowledgeResults,
markAsRead: useWebMutation(MARK_CONVERSATION_READ_REF),
messages: useWebQuery(
MESSAGES_LIST_QUERY_REF,
Expand Down
23 changes: 13 additions & 10 deletions apps/web/src/app/inbox/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,15 +283,18 @@ function InboxContent(): React.JSX.Element | null {
}
};
}, []);
const handleOpenConversationFromNotification = useCallback((conversationId: Id<"conversations">) => {
if (typeof window === "undefined") {
return;
}
const url = new URL(window.location.href);
url.pathname = "/inbox";
url.searchParams.set("conversationId", conversationId);
window.location.assign(url.toString());
}, []);
const handleOpenConversationFromNotification = useCallback(
(conversationId: Id<"conversations">) => {
if (typeof window === "undefined") {
return;
}
const url = new URL(window.location.href);
url.pathname = "/inbox";
url.searchParams.set("conversationId", conversationId);
window.location.assign(url.toString());
},
[]
);
useInboxAttentionCues({
conversations,
selectedConversationId,
Expand Down Expand Up @@ -382,7 +385,7 @@ function InboxContent(): React.JSX.Element | null {
setInputValue((prev) => `${prev}${prev ? "\n\n" : ""}${item.content}`);
setLastInsertedSnippetId(item.id as Id<"snippets">);
} else if (action === "link" && item.type === "article" && item.slug) {
setInputValue((prev) => `${prev}${prev ? "\n\n" : ""}[${item.title}](/help/${item.slug})`);
setInputValue((prev) => `${prev}${prev ? "\n\n" : ""}[${item.title}](article:${item.id})`);
} else {
setInputValue((prev) => `${prev}${prev ? "\n\n" : ""}${item.content}`);
}
Expand Down
34 changes: 31 additions & 3 deletions apps/widget/src/components/conversationView/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,26 @@ export function ConversationMessageList({
renderedMessages,
messagesEndRef,
}: ConversationMessageListProps) {
const handleMessageClick = (event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target;
if (!target) {
return;
}
const element = target instanceof Element ? target : (target as Text).parentElement;
if (!element) {
return;
}
const articleLink = element.closest("[data-article-id]");
if (articleLink) {
event.preventDefault();
event.stopPropagation();
const articleId = articleLink.getAttribute("data-article-id");
if (articleId) {
onSelectArticle(articleId as Id<"articles">);
}
Comment on lines +74 to +90
}
};

return (
<div className="opencom-messages" data-testid="widget-message-list">
{!messages || messages.length === 0 ? (
Expand Down Expand Up @@ -102,7 +122,9 @@ export function ConversationMessageList({

return (
<div key={msg._id} className="opencom-message-wrapper">
{showTimestamp && <div className="opencom-message-timestamp">{formatTime(msg._creationTime)}</div>}
{showTimestamp && (
<div className="opencom-message-timestamp">{formatTime(msg._creationTime)}</div>
)}
<div
className={`opencom-message opencom-message-${
msg.senderType === "visitor" ? "user" : "agent"
Expand All @@ -115,7 +137,10 @@ export function ConversationMessageList({
</span>
)}
{isHumanAgent && (
<span className="opencom-human-badge" data-testid={`widget-human-agent-badge-${msg._id}`}>
<span
className="opencom-human-badge"
data-testid={`widget-human-agent-badge-${msg._id}`}
>
<User /> {humanAgentName}
</span>
)}
Expand All @@ -125,6 +150,7 @@ export function ConversationMessageList({
dangerouslySetInnerHTML={{
__html: renderedMessages.get(msg._id) ?? "",
}}
onClick={handleMessageClick}
/>
)}
{msg.attachments && msg.attachments.length > 0 && (
Expand All @@ -150,7 +176,9 @@ export function ConversationMessageList({
type="button"
className="opencom-ai-source-link"
data-testid={`widget-ai-source-link-${aiData._id}-${sourceIndex}`}
onClick={() => onSelectArticle(articleSourceId as Id<"articles">)}
onClick={() =>
onSelectArticle(articleSourceId as Id<"articles">)
}
>
{source.title}
</button>
Expand Down
Loading
Loading