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
9 changes: 9 additions & 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 Expand Up @@ -648,3 +649,11 @@ If you are about to:
- create a ref inside a React component

stop and use one of the standard patterns above instead.

<!-- convex-ai-start -->
This project uses [Convex](https://convex.dev) as its backend.

When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.

Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<!-- convex-ai-start -->
This project uses [Convex](https://convex.dev) as its backend.

When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.

Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. claude.md uses npx 📘 Rule violation § Compliance

The new CLAUDE.md instructs running npx convex ai-files install, which violates the requirement
to use pnpm for package management/CLI execution. This introduces non-standard tooling guidance
for the repo.
Agent Prompt
## Issue description
`CLAUDE.md` includes a `npx` command (`npx convex ai-files install`), which violates the repo’s PNPM-only rule.

## Issue Context
Docs are included in the compliance scan; guidance must not recommend `npx`/`npm`/`yarn` when `pnpm` alternatives exist.

## Fix Focus Areas
- CLAUDE.md[1-7]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

<!-- convex-ai-end -->
4 changes: 3 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ 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)
- [ ] make clicking anywhere on the settings headers expand that section, not just the show/hide button
- [ ] add full evals, traces, etc. to the AI agent

apps/web/src/app/outbound/[id]/OutboundTriggerPanel.tsx
Comment on lines +67 to 71
Expand Down
2 changes: 1 addition & 1 deletion apps/landing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"framer-motion": "^12.34.3",
"geist": "^1.7.0",
"lucide-react": "^0.469.0",
"next": "^15.5.10",
"next": "^15.5.15",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"tailwind-merge": "^2.1.0"
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@opencom/convex": "workspace:*",
"@opencom/types": "workspace:*",
"@react-native-async-storage/async-storage": "^2.1.2",
"convex": "^1.32.0",
"convex": "1.35.1",
"expo": "~54.0.33",
"expo-clipboard": "^8.0.8",
"expo-constants": "~18.0.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/e2e/global-teardown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async function globalTeardown() {
args: {
secret: adminSecret,
name: "testing/helpers:cleanupE2ETestData",
mutationArgs: {},
mutationArgsJson: JSON.stringify({}),
},
format: "json",
}),
Expand Down
6 changes: 5 additions & 1 deletion apps/web/e2e/helpers/test-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ async function callInternalMutation<T>(path: string, args: Record<string, unknow
},
body: JSON.stringify({
path: "testAdmin:runTestMutation",
args: { secret: getAdminSecret(), name: path, mutationArgs: args },
args: {
secret: getAdminSecret(),
name: path,
mutationArgsJson: JSON.stringify(args),
},
format: "json",
}),
});
Expand Down
2 changes: 2 additions & 0 deletions apps/web/e2e/helpers/widget-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,8 @@ export async function waitForSurveyVisible(page: Page, timeout = 10000): Promise
export async function submitNPSRating(page: Page, rating: number): Promise<void> {
const frame = getWidgetContainer(page);

await dismissTour(page);

// Click the rating button (0-10)
await frame
.locator(
Expand Down
12 changes: 0 additions & 12 deletions apps/web/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,6 @@ const nextConfig = {
// Reduce memory usage during webpack compilation
webpackMemoryOptimizations: true,
},
webpack: (config, { dev }) => {
if (dev) {
// Use filesystem cache to reduce in-memory pressure during dev
config.cache = {
type: "filesystem",
buildDependencies: {
config: [__filename],
},
};
}
return config;
},
async headers() {
return [
{
Expand Down
4 changes: 2 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@
"@opencom/types": "workspace:*",
"@opencom/ui": "workspace:*",
"@opencom/web-shared": "workspace:*",
"convex": "^1.32.0",
"convex": "1.35.1",
"dompurify": "^3.3.1",
"fflate": "^0.8.2",
"lucide-react": "^0.469.0",
"markdown-it": "^14.1.1",
"next": "^15.5.10",
"next": "^15.5.15",
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
Expand Down
28 changes: 20 additions & 8 deletions apps/web/src/app/articles/hooks/useArticlesAdminConvex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import type { Id } from "@opencom/convex/dataModel";
import {
useWebAction,
useWebMutation,
useWebQuery,
webActionRef,
webMutationRef,
webQueryRef,
} from "@/lib/convex/hooks";
Expand Down Expand Up @@ -79,6 +81,18 @@ type LogExportArgs = WorkspaceArgs & {
recordCount: number;
};

type BackfillEmbeddingsArgs = WorkspaceArgs & {
contentTypes?: ("article" | "internalArticle" | "snippet")[];
batchSize?: number;
model?: string;
};

type BackfillEmbeddingsResult = {
total: number;
processed: number;
skipped: number;
};

const ARTICLES_LIST_QUERY_REF = webQueryRef<WorkspaceArgs, ArticleListItem[]>("articles:list");
const COLLECTIONS_LIST_HIERARCHY_QUERY_REF = webQueryRef<WorkspaceArgs, CollectionListItem[]>(
"collections:listHierarchy"
Expand Down Expand Up @@ -107,6 +121,9 @@ const GENERATE_ASSET_UPLOAD_URL_REF = webMutationRef<GenerateAssetUploadUrlArgs,
"articles:generateAssetUploadUrl"
);
const LOG_EXPORT_REF = webMutationRef<LogExportArgs, null>("auditLogs:logExport");
const BACKFILL_EMBEDDINGS_REF = webActionRef<BackfillEmbeddingsArgs, BackfillEmbeddingsResult>(
"embeddings:backfillExisting"
);

type UseArticlesAdminConvexOptions = {
workspaceId?: Id<"workspaces"> | null;
Expand All @@ -120,10 +137,7 @@ export function useArticlesAdminConvex({
exportSourceId,
}: UseArticlesAdminConvexOptions) {
return {
articles: useWebQuery(
ARTICLES_LIST_QUERY_REF,
workspaceId ? { workspaceId } : "skip"
),
articles: useWebQuery(ARTICLES_LIST_QUERY_REF, workspaceId ? { workspaceId } : "skip"),
collections: useWebQuery(
COLLECTIONS_LIST_HIERARCHY_QUERY_REF,
workspaceId ? { workspaceId } : "skip"
Expand All @@ -135,10 +149,7 @@ export function useArticlesAdminConvex({
IMPORT_HISTORY_QUERY_REF,
workspaceId ? { workspaceId, limit: 10 } : "skip"
),
importSources: useWebQuery(
IMPORT_SOURCES_QUERY_REF,
workspaceId ? { workspaceId } : "skip"
),
importSources: useWebQuery(IMPORT_SOURCES_QUERY_REF, workspaceId ? { workspaceId } : "skip"),
logExport: useWebMutation(LOG_EXPORT_REF),
markdownExport: useWebQuery(
EXPORT_MARKDOWN_QUERY_REF,
Expand All @@ -154,5 +165,6 @@ export function useArticlesAdminConvex({
restoreImportRun: useWebMutation(RESTORE_IMPORT_RUN_REF),
syncMarkdownFolder: useWebMutation(SYNC_MARKDOWN_FOLDER_REF),
unpublishArticle: useWebMutation(UNPUBLISH_ARTICLE_REF),
backfillEmbeddings: useWebAction(BACKFILL_EMBEDDINGS_REF),
};
}
83 changes: 64 additions & 19 deletions apps/web/src/app/articles/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import { AppLayout } from "@/components/AppLayout";
import { Button } from "@opencom/ui";
import { Plus } from "lucide-react";
import { Plus, RefreshCw } from "lucide-react";
import Link from "next/link";
import type { Id } from "@opencom/convex/dataModel";
import { strToU8, zipSync } from "fflate";
Expand Down Expand Up @@ -39,12 +39,8 @@ function ArticlesContent() {
const searchParams = useSearchParams();
const { activeWorkspace } = useAuth();
const [searchQuery, setSearchQuery] = useState("");
const [collectionFilter, setCollectionFilter] = useState<CollectionFilter>(
ALL_COLLECTION_FILTER
);
const [visibilityFilter, setVisibilityFilter] = useState<VisibilityFilter>(
ALL_VISIBILITY_FILTER
);
const [collectionFilter, setCollectionFilter] = useState<CollectionFilter>(ALL_COLLECTION_FILTER);
const [visibilityFilter, setVisibilityFilter] = useState<VisibilityFilter>(ALL_VISIBILITY_FILTER);
const [statusFilter, setStatusFilter] = useState<StatusFilter>(ALL_STATUS_FILTER);
const [importSourceName, setImportSourceName] = useState("");
const [importTargetCollectionId, setImportTargetCollectionId] = useState<
Expand All @@ -68,6 +64,9 @@ function ArticlesContent() {
const [deleteTarget, setDeleteTarget] = useState<DeleteArticleTarget | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [isDeletingArticle, setIsDeletingArticle] = useState(false);
const [isBackfillingEmbeddings, setIsBackfillingEmbeddings] = useState(false);
const [backfillNotice, setBackfillNotice] = useState<string | null>(null);
const [backfillError, setBackfillError] = useState<string | null>(null);
const folderInputRef = useRef<HTMLInputElement | null>(null);
const createQueryHandledRef = useRef(false);
const {
Expand All @@ -84,6 +83,7 @@ function ArticlesContent() {
restoreImportRun,
syncMarkdownFolder,
unpublishArticle,
backfillEmbeddings,
} = useArticlesAdminConvex({
workspaceId: activeWorkspace?._id,
isExporting,
Expand Down Expand Up @@ -159,6 +159,31 @@ function ArticlesContent() {
}
};

const handleBackfillEmbeddings = async () => {
if (!activeWorkspace?._id) {
return;
}

setIsBackfillingEmbeddings(true);
setBackfillError(null);
setBackfillNotice(null);

try {
const result = await backfillEmbeddings({
workspaceId: activeWorkspace._id,
contentTypes: ["article", "internalArticle"],
});
setBackfillNotice(
`Embeddings backfilled: ${result.processed} processed, ${result.skipped} skipped (already existed)`
);
} catch (error) {
console.error("Failed to backfill embeddings:", error);
setBackfillError(error instanceof Error ? error.message : "Failed to backfill embeddings.");
} finally {
setIsBackfillingEmbeddings(false);
}
};

const buildImportPayload = async () =>
Promise.all(
selectedImportItems.map(async (item) => ({
Expand Down Expand Up @@ -517,18 +542,38 @@ function ArticlesContent() {
<h1 className="text-2xl font-bold">Articles</h1>
<p className="text-gray-500">Manage public and internal knowledge articles</p>
</div>
<div className="flex gap-2">
<Link href="/articles/collections">
<Button variant="outline">Manage Collections</Button>
</Link>
<Button variant="outline" onClick={() => void handleCreateArticle("internal")}>
<Plus className="h-4 w-4 mr-2" />
New Internal Article
</Button>
<Button onClick={() => void handleCreateArticle("public")}>
<Plus className="h-4 w-4 mr-2" />
New Article
</Button>
<div className="flex flex-col gap-2 items-end">
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => void handleBackfillEmbeddings()}
disabled={
isBackfillingEmbeddings ||
!activeWorkspace?._id ||
(activeWorkspace.role !== "owner" && activeWorkspace.role !== "admin")
}
>
<RefreshCw
className={`h-4 w-4 mr-2 ${isBackfillingEmbeddings ? "animate-spin" : ""}`}
/>
{isBackfillingEmbeddings ? "Refreshing..." : "Refresh Embeddings"}
</Button>
<Link href="/articles/collections">
<Button variant="outline">Manage Collections</Button>
</Link>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => void handleCreateArticle("internal")}>
<Plus className="h-4 w-4 mr-2" />
New Internal Article
</Button>
<Button onClick={() => void handleCreateArticle("public")}>
<Plus className="h-4 w-4 mr-2" />
New Article
</Button>
</div>
{backfillNotice && <p className="text-sm text-green-600">{backfillNotice}</p>}
{backfillError && <p className="text-sm text-red-600">{backfillError}</p>}
</div>
</div>

Expand Down
46 changes: 46 additions & 0 deletions apps/web/src/app/inbox/InboxAiReviewPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { Id } from "@opencom/convex/dataModel";
import { InboxAiReviewPanel } from "./InboxAiReviewPanel";
import type { InboxAiResponse } from "./inboxRenderTypes";

function messageId(value: string): Id<"messages"> {
return value as Id<"messages">;
}

function responseId(value: string): Id<"aiResponses"> {
return value as Id<"aiResponses">;
}

describe("InboxAiReviewPanel", () => {
it("renders persisted model and provider metadata for AI responses", () => {
const response: InboxAiResponse = {
_id: responseId("response_1"),
createdAt: Date.now(),
query: "How do I reset my password?",
response: "Go to Settings > Security > Reset Password.",
confidence: 0.82,
model: "openai/gpt-5-nano",
provider: "openai",
handedOff: false,
messageId: messageId("message_1"),
sources: [],
deliveredResponseContext: null,
generatedResponseContext: null,
};

render(
<InboxAiReviewPanel
aiResponses={[response]}
orderedAiResponses={[response]}
selectedConversation={null}
onOpenArticle={vi.fn()}
onJumpToMessage={vi.fn()}
getHandoffReasonLabel={(reason) => reason ?? "No reason"}
/>
);

expect(screen.getByText("Model openai/gpt-5-nano")).toBeInTheDocument();
expect(screen.getByText("Provider openai")).toBeInTheDocument();
});
});
6 changes: 6 additions & 0 deletions apps/web/src/app/inbox/InboxAiReviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ export function InboxAiReviewPanel({
<span className="rounded bg-muted px-2 py-0.5">
{confidenceLabel} {Math.round(confidenceValue * 100)}%
</span>
<span className="rounded bg-muted px-2 py-0.5" data-testid={`inbox-ai-review-model-${response._id}`}>
Model {response.model}
</span>
<span className="rounded bg-muted px-2 py-0.5" data-testid={`inbox-ai-review-provider-${response._id}`}>
Provider {response.provider}
</span>
{response.feedback && (
<span className="rounded bg-muted px-2 py-0.5">
Feedback {response.feedback === "helpful" ? "helpful" : "not helpful"}
Expand Down
Loading
Loading