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
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ Goal: ship a professional open-source customer messaging platform with strong de
- [ ] pull available models from provider API and display them in the settings UI to control which model is used
- [ ] allow customising the agent's system prompt?
- [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.


apps/web/src/app/outbound/[id]/OutboundTriggerPanel.tsx
Comment on lines +67 to 71
Expand Down
3 changes: 2 additions & 1 deletion apps/web/e2e/widget.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,8 @@ test.describe("Widget Email Capture", () => {
await expect(capturePrompt).not.toBeVisible({ timeout: 20000 });
});

test("should dismiss email capture prompt", async ({ page }) => {
// Skipped since we have hidden the dismiss button for now
test.skip("should dismiss email capture prompt", async ({ page }) => {
const widget = await openWidgetChatOrSkip(page);
await startConversationIfNeeded(widget);

Expand Down
68 changes: 60 additions & 8 deletions apps/web/src/app/inbox/InboxThreadPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ function buildProps(overrides?: Partial<React.ComponentProps<typeof InboxThreadP
workflowError: null,
highlightedMessageId: null,
inputValue: "",
pendingAttachments: [],
isSending: false,
isUploadingAttachments: false,
isResolving: false,
isConvertingTicket: false,
showKnowledgePicker: true,
Expand All @@ -113,6 +115,8 @@ function buildProps(overrides?: Partial<React.ComponentProps<typeof InboxThreadP
onInputChange: vi.fn(),
onInputKeyDown: vi.fn(),
onSendMessage: vi.fn(),
onUploadAttachments: vi.fn(),
onRemovePendingAttachment: vi.fn(),
onToggleKnowledgePicker: vi.fn(),
onKnowledgeSearchChange: vi.fn(),
onCloseKnowledgePicker: vi.fn(),
Expand All @@ -126,14 +130,16 @@ function buildProps(overrides?: Partial<React.ComponentProps<typeof InboxThreadP
}

describe("InboxThreadPane", () => {
it("shows recent content, snippet actions, and snippet workflow controls in the consolidated picker", () => {
it("shows recent content and snippet actions in the consolidated picker", () => {
const props = buildProps();
render(<InboxThreadPane {...props} />);

expect(screen.getByText("Recently Used")).toBeInTheDocument();
expect(screen.getByText("Snippets")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save snippet" })).toBeEnabled();
expect(screen.getByRole("button", { name: /Update "Billing follow-up"/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Save snippet" })).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Update "Billing follow-up"/ })
).not.toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "Insert Link" }));
expect(props.onInsertKnowledgeContent).toHaveBeenCalledWith(
Expand All @@ -142,15 +148,17 @@ describe("InboxThreadPane", () => {
);
});

it("invokes the inline snippet workflow controls from the composer", () => {
it("does not render inline snippet workflow controls in the composer", () => {
const props = buildProps();
render(<InboxThreadPane {...props} />);

fireEvent.click(screen.getByRole("button", { name: "Save snippet" }));
fireEvent.click(screen.getByRole("button", { name: /Update "Billing follow-up"/ }));
expect(screen.queryByRole("button", { name: "Save snippet" })).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Update "Billing follow-up"/ })
).not.toBeInTheDocument();

expect(props.onSaveDraftAsSnippet).toHaveBeenCalledTimes(1);
expect(props.onUpdateSnippetFromDraft).toHaveBeenCalledTimes(1);
expect(props.onSaveDraftAsSnippet).not.toHaveBeenCalled();
expect(props.onUpdateSnippetFromDraft).not.toHaveBeenCalled();
});

it("inserts snippet content directly from the consolidated picker", () => {
Expand Down Expand Up @@ -189,4 +197,48 @@ describe("InboxThreadPane", () => {
expect.objectContaining({ id: "article-3", type: "internalArticle" })
);
});

it("renders message attachments and queued reply attachments", () => {
const props = buildProps({
inputValue: "",
pendingAttachments: [
{
attachmentId: "support-attachment-1" as Id<"supportAttachments">,
fileName: "error-screenshot.png",
mimeType: "image/png",
size: 2048,
status: "staged",
},
],
messages: [
{
_id: messageId("message-attachment"),
senderType: "visitor" as const,
content: "",
createdAt: Date.now(),
attachments: [
{
_id: "attached-file-1",
fileName: "browser-log.txt",
mimeType: "text/plain",
size: 1536,
url: "https://example.com/browser-log.txt",
},
],
},
],
});

render(<InboxThreadPane {...props} />);

expect(screen.getByText("browser-log.txt")).toBeInTheDocument();
expect(screen.getByTestId("inbox-pending-attachments")).toBeInTheDocument();
expect(screen.getByText("error-screenshot.png")).toBeInTheDocument();
expect(screen.getByTestId("inbox-send-button")).toBeEnabled();

fireEvent.click(screen.getByLabelText("Remove error-screenshot.png"));
expect(props.onRemovePendingAttachment).toHaveBeenCalledWith(
"support-attachment-1" as Id<"supportAttachments">
);
});
});
157 changes: 135 additions & 22 deletions apps/web/src/app/inbox/InboxThreadPane.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
"use client";

import { useRef } from "react";
import { Button, Card, Input } from "@opencom/ui";
import {
SUPPORT_ATTACHMENT_ACCEPT,
formatSupportAttachmentSize,
type StagedSupportAttachment,
} from "@opencom/web-shared";
import {
ArrowLeft,
ArrowUpRight,
Expand All @@ -16,7 +22,7 @@ import {
ShieldAlert,
Ticket,
X,
Zap,
// Zap,
} from "lucide-react";
import type { Id } from "@opencom/convex/dataModel";
import {
Expand All @@ -35,7 +41,9 @@ interface InboxThreadPaneProps {
workflowError: string | null;
highlightedMessageId: Id<"messages"> | null;
inputValue: string;
pendingAttachments?: StagedSupportAttachment<Id<"supportAttachments">>[];
isSending: boolean;
isUploadingAttachments?: boolean;
isResolving: boolean;
isConvertingTicket: boolean;
showKnowledgePicker: boolean;
Expand All @@ -62,6 +70,8 @@ interface InboxThreadPaneProps {
onInputChange: (value: string) => void;
onInputKeyDown: (event: React.KeyboardEvent) => void;
onSendMessage: () => void;
onUploadAttachments?: (files: File[]) => void;
onRemovePendingAttachment?: (attachmentId: Id<"supportAttachments">) => void;
onToggleKnowledgePicker: () => void;
onKnowledgeSearchChange: (value: string) => void;
onCloseKnowledgePicker: () => void;
Expand Down Expand Up @@ -124,7 +134,9 @@ export function InboxThreadPane({
workflowError,
highlightedMessageId,
inputValue,
pendingAttachments = [],
isSending,
isUploadingAttachments = false,
isResolving,
isConvertingTicket,
showKnowledgePicker,
Expand All @@ -138,9 +150,9 @@ export function InboxThreadPane({
isSidecarEnabled,
suggestionsCount,
isSuggestionsCountLoading,
canSaveDraftAsSnippet,
canUpdateSnippetFromDraft,
lastInsertedSnippetName,
// canSaveDraftAsSnippet,
// canUpdateSnippetFromDraft,
// lastInsertedSnippetName,
replyInputRef,
onBackToList,
onResolveConversation,
Expand All @@ -151,20 +163,32 @@ export function InboxThreadPane({
onInputChange,
onInputKeyDown,
onSendMessage,
onUploadAttachments = () => {},
onRemovePendingAttachment = () => {},
onToggleKnowledgePicker,
onKnowledgeSearchChange,
onCloseKnowledgePicker,
onInsertKnowledgeContent,
onSaveDraftAsSnippet,
onUpdateSnippetFromDraft,
// onSaveDraftAsSnippet,
// onUpdateSnippetFromDraft,
getConversationIdentityLabel,
getHandoffReasonLabel,
}: InboxThreadPaneProps): React.JSX.Element {
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const normalizedKnowledgeSearch = knowledgeSearch.trim();
const isSearchingKnowledge = normalizedKnowledgeSearch.length > 0;
const hasRecentContent = Boolean(recentContent && recentContent.length > 0);
const hasSnippetLibrary = Boolean(allSnippets && allSnippets.length > 0);
const hasKnowledgeResults = Boolean(knowledgeResults && knowledgeResults.length > 0);
const canSendReply = inputValue.trim().length > 0 || pendingAttachments.length > 0;

const handleAttachmentInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
if (files.length > 0) {
onUploadAttachments(files);
}
event.target.value = "";
};

const renderKnowledgeActions = (item: InboxKnowledgeItem) => {
if (item.type === "snippet") {
Expand Down Expand Up @@ -440,14 +464,59 @@ export function InboxThreadPane({
From: {message.emailMetadata.from}
</div>
)}
<p className="whitespace-pre-wrap">{message.content.replace(/<[^>]*>/g, "")}</p>
{message.content.trim().length > 0 && (
<p className="whitespace-pre-wrap">
{message.content.replace(/<[^>]*>/g, "")}
</p>
)}
{message.emailMetadata?.attachments &&
message.emailMetadata.attachments.length > 0 && (
<div className="flex items-center gap-1 text-xs opacity-70 mt-2">
<Paperclip className="h-3 w-3" />
<span>{message.emailMetadata.attachments.length} attachment(s)</span>
</div>
)}
{message.attachments && message.attachments.length > 0 && (
<div className="mt-3 space-y-2">
{message.attachments.map((attachment) => (
<div
key={attachment._id}
className={`flex items-center justify-between rounded-lg border px-3 py-2 text-sm ${
message.senderType === "agent"
? "border-primary-foreground/20 bg-primary-foreground/10 text-primary-foreground"
: "border-border bg-background/80 text-foreground"
}`}
>
{attachment.url ? (
<a
href={attachment.url}
target="_blank"
rel="noreferrer"
className="flex min-w-0 flex-1 items-center justify-between gap-3"
>
<span className="flex min-w-0 items-center gap-2">
<Paperclip className="h-3.5 w-3.5 flex-shrink-0" />
<span className="truncate">{attachment.fileName}</span>
</span>
<span className="flex-shrink-0 text-xs opacity-70">
{formatSupportAttachmentSize(attachment.size)}
</span>
</a>
) : (
<>
<span className="flex min-w-0 items-center gap-2">
<Paperclip className="h-3.5 w-3.5 flex-shrink-0" />
<span className="truncate">{attachment.fileName}</span>
</span>
<span className="ml-3 flex-shrink-0 text-xs opacity-70">
{formatSupportAttachmentSize(attachment.size)}
</span>
</>
)}
</div>
))}
</div>
)}
<div className="flex items-center gap-2 text-xs opacity-70 mt-1">
<span>
{message.channel === "email" && <Mail className="h-3 w-3 inline mr-1" />}
Expand Down Expand Up @@ -563,7 +632,25 @@ export function InboxThreadPane({
)}

<div className="flex flex-wrap gap-2">
<input
ref={attachmentInputRef}
type="file"
multiple
accept={SUPPORT_ATTACHMENT_ACCEPT}
className="hidden"
onChange={handleAttachmentInputChange}
/>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => attachmentInputRef.current?.click()}
title="Attach files"
disabled={isSending || isUploadingAttachments}
data-testid="inbox-attach-button"
>
<Paperclip className="h-4 w-4" />
</Button>
<Button
variant={showKnowledgePicker ? "default" : "ghost"}
size="icon"
Expand All @@ -572,37 +659,63 @@ export function InboxThreadPane({
>
<BookOpen className="h-4 w-4" />
</Button>
<Button
{/* <Button
variant="outline"
size="sm"
onClick={onSaveDraftAsSnippet}
disabled={!canSaveDraftAsSnippet}
>
<Zap className="mr-2 h-4 w-4" />
Save snippet
</Button>
{canUpdateSnippetFromDraft ? (
</Button> */}
{/* {canUpdateSnippetFromDraft ? (
<Button variant="outline" size="sm" onClick={onUpdateSnippetFromDraft}>
Update {lastInsertedSnippetName ? `"${lastInsertedSnippetName}"` : "snippet"}
</Button>
) : null}
) : null} */}
</div>
<div className="min-w-0 flex-1 space-y-2">
{pendingAttachments.length > 0 && (
<div className="flex flex-wrap gap-2" data-testid="inbox-pending-attachments">
{pendingAttachments.map((attachment) => (
<div
key={attachment.attachmentId}
className="inline-flex items-center gap-2 rounded-full border bg-muted px-3 py-1 text-xs"
>
<Paperclip className="h-3 w-3" />
<span className="max-w-[220px] truncate">{attachment.fileName}</span>
<span className="text-muted-foreground">
{formatSupportAttachmentSize(attachment.size)}
</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => onRemovePendingAttachment(attachment.attachmentId)}
aria-label={`Remove ${attachment.fileName}`}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<Input
ref={replyInputRef}
value={inputValue}
onChange={(event) => onInputChange(event.target.value)}
onKeyDown={onInputKeyDown}
placeholder="Type a message... (/ or Ctrl+K for knowledge)"
data-testid="inbox-reply-input"
disabled={isSending || isUploadingAttachments}
className="flex-1"
/>
</div>
<Input
ref={replyInputRef}
value={inputValue}
onChange={(event) => onInputChange(event.target.value)}
onKeyDown={onInputKeyDown}
placeholder="Type a message... (/ or Ctrl+K for knowledge)"
data-testid="inbox-reply-input"
disabled={isSending}
className="flex-1"
/>
<Button
onClick={onSendMessage}
size="icon"
data-testid="inbox-send-button"
aria-label="Send reply"
disabled={isSending || !inputValue.trim()}
disabled={isSending || isUploadingAttachments || !canSendReply}
>
<Send className="h-4 w-4" />
</Button>
Expand Down
Loading
Loading