Merge remote-tracking branch 'upstream/dev' into fix/electron-nextjs

This commit is contained in:
Anish Sarkar 2026-05-25 18:01:06 +05:30
commit 96da8498e6
27 changed files with 362 additions and 191 deletions

View file

@ -33,7 +33,7 @@ Map outcomes to your `status`:
- Any other `"Error: …"``status=error` and relay the tool's message verbatim as `next_step`.
- HITL rejection → `status=blocked` with `next_step="User declined this filesystem action. Do not retry."`.
You construct the structured `evidence` fields from your own knowledge of what you called and what you observed — the tools do not return them. `chunk_ids` apply only to `<priority_documents>` hits; for local-file operations leave them `null`. Never report values you did not actually see.
You construct the structured `evidence` fields from your own knowledge of what you called and what you observed — the tools do not return them. Never report values you did not actually see. (`chunk_ids` is always `null` in desktop mode — see "Chunk citations in your prose" below.)
## Chunk citations in your prose

View file

@ -5,6 +5,11 @@
# inside the desktop app. Set to your production frontend domain.
HOSTED_FRONTEND_URL=https://surfsense.net
# Runtime override for the above (read at app start, no rebuild required).
# Useful for self-hosters whose backend NEXT_FRONTEND_URL differs from the
# value baked into the official desktop builds. Leave empty to use HOSTED_FRONTEND_URL.
# SURFSENSE_HOSTED_FRONTEND_URL_OVERRIDE=
# PostHog analytics (leave empty to disable)
POSTHOG_KEY=
POSTHOG_HOST=https://assets.surfsense.com

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

View file

@ -55,6 +55,11 @@ mac:
NSAccessibilityUsageDescription: "SurfSense uses accessibility features to bring the app to the foreground and interact with the active application when you use desktop assists."
NSScreenCaptureUsageDescription: "SurfSense uses screen capture so you can attach a selected region to chat (Screenshot Assist) or capture the full screen from the composer."
NSAppleEventsUsageDescription: "SurfSense uses Apple Events to interact with the active application."
# `surfsense://` scheme — install-time registration for LaunchServices.
CFBundleURLTypes:
- CFBundleURLName: com.surfsense.desktop
CFBundleURLSchemes:
- surfsense
target:
- target: dmg
arch: [x64, arm64]
@ -72,7 +77,7 @@ nsis:
createDesktopShortcut: true
createStartMenuShortcut: true
linux:
icon: assets/icon.png
icon: assets/icons/
category: Utility
artifactName: "${productName}-${version}-${arch}.${ext}"
mimeTypes:

View file

@ -60,6 +60,11 @@ export function setupDeepLinks(): boolean {
app.setAsDefaultProtocolClient(PROTOCOL);
}
// Cold-start on Windows/Linux: protocol URL arrives via argv of the
// first instance, not via `second-instance` or `open-url`.
const cold = process.argv.find((arg) => arg.startsWith(`${PROTOCOL}://`));
if (cold) handleDeepLink(cold);
return true;
}

View file

@ -49,6 +49,7 @@ export async function startNextServer(): Promise<void> {
env: {
...process.env,
PORT: String(serverPort),
// Loopback bind: avoids 0.0.0.0 leaking into request.url and redirect origins.
HOSTNAME: SERVER_HOST,
NODE_ENV: 'production',
},

View file

@ -2,14 +2,31 @@ import { app, BrowserWindow, shell, session } from 'electron';
import path from 'path';
import { trackEvent } from './analytics';
import { showErrorDialog } from './errors';
import { getServerOrigin } from './server';
import { getServerOrigin, getServerPort } from './server';
import { setActiveSearchSpaceId } from './active-search-space';
const isDev = !app.isPackaged;
const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string;
const isMac = process.platform === 'darwin';
const WINDOW_TITLE = 'SurfSense';
function getHostedFrontendUrl(): string {
return (
process.env.SURFSENSE_HOSTED_FRONTEND_URL_OVERRIDE ||
process.env.HOSTED_FRONTEND_URL ||
'https://surfsense.net'
);
}
function getHostedFrontendHosts(): string[] {
try {
const host = new URL(getHostedFrontendUrl()).host;
const sibling = host.startsWith('www.') ? host.slice(4) : `www.${host}`;
return Array.from(new Set([host, sibling]));
} catch {
return [];
}
}
let mainWindow: BrowserWindow | null = null;
let isQuitting = false;
@ -68,11 +85,48 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow {
return { action: 'deny' };
});
const filter = { urls: [`${HOSTED_FRONTEND_URL}/*`] };
session.defaultSession.webRequest.onBeforeRequest(filter, (details, callback) => {
const rewritten = details.url.replace(HOSTED_FRONTEND_URL, getServerOrigin());
callback({ redirectURL: rewritten });
});
const hostedHosts = getHostedFrontendHosts();
const rewriteFilter = {
urls: hostedHosts.flatMap((h) => [`http://${h}/*`, `https://${h}/*`]),
};
if (rewriteFilter.urls.length > 0) {
session.defaultSession.webRequest.onBeforeRequest(rewriteFilter, (details, callback) => {
try {
const u = new URL(details.url);
const originalHost = u.host;
const local = new URL(getServerOrigin());
u.protocol = local.protocol;
u.host = local.host;
trackEvent('desktop_oauth_redirect_intercepted', {
host: originalHost,
path: u.pathname,
rewritten_to_port: getServerPort(),
});
callback({ redirectURL: u.toString() });
} catch {
callback({});
}
});
}
// Diagnostic: connector callback landing somewhere other than localhost
// means the rewrite missed and the user is stranded off-app.
session.defaultSession.webRequest.onCompleted(
{ urls: ['*://*/dashboard/*/connectors/callback*'] },
(details) => {
try {
const u = new URL(details.url);
if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') return;
trackEvent('desktop_oauth_redirect_missed', {
host: u.host,
path: u.pathname,
status_code: details.statusCode,
});
} catch {
// ignore malformed URLs
}
}
);
mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
console.error(`Failed to load ${validatedURL}: ${errorDescription} (${errorCode})`);

View file

@ -1,6 +1,5 @@
import { type NextRequest, NextResponse } from "next/server";
const OAUTH_RESULT_COOKIE = "connector_oauth_result";
import { OAUTH_RESULT_COOKIE, type OAuthCallbackResult } from "@/contracts/types/oauth.types";
export async function GET(
request: NextRequest,
@ -9,12 +8,13 @@ export async function GET(
const { search_space_id } = await params;
const searchParams = request.nextUrl.searchParams;
const result = JSON.stringify({
const payload: OAuthCallbackResult = {
success: searchParams.get("success"),
error: searchParams.get("error"),
connector: searchParams.get("connector"),
connectorId: searchParams.get("connectorId"),
});
};
const result = JSON.stringify(payload);
const redirectUrl = new URL(`/dashboard/${search_space_id}/new-chat`, request.url);

View file

@ -31,7 +31,7 @@ import {
deleteMemberMutationAtom,
updateMemberMutationAtom,
} from "@/atoms/members/members-mutation.atoms";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { membersAtom, myAccessAtom, canPerform } from "@/atoms/members/members-query.atoms";
import {
AlertDialog,
AlertDialogAction,
@ -126,14 +126,9 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
const hasPermission = useCallback(
(permission: string) => {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes(permission) ?? false;
},
(permission: string) => canPerform(access, permission),
[access]
);
const { data: members = [], isLoading: membersLoading } = useAtomValue(membersAtom);
const { mutateAsync: updateMember } = useAtomValue(updateMemberMutationAtom);

View file

@ -39,3 +39,38 @@ export const myAccessAtom = atomWithQuery((get) => {
},
};
});
/**
* Helper function to check if the current user has a specific permission.
*
* @param access - The access object from useAtomValue(myAccessAtom)
* @param permission - The permission string to check
* @returns boolean indicating if the user has the permission
*
* @example
* const access = useAtomValue(myAccessAtom);
* if (canPerform(access, 'manage_members')) { ... }
*/
export function canPerform(
access: { is_owner: boolean; permissions?: string[] } | null | undefined,
permission: string
): boolean {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes(permission) ?? false;
}
/**
* Hook wrapper for canPerform that reads from myAccessAtom internally.
* Use this if you want to avoid calling useAtomValue(myAccessAtom) separately.
*
* @param permission - The permission string to check
* @returns boolean indicating if the user has the permission
*
* @example
* const canManageMembers = usePermissionGate('manage_members');
*/
export function usePermissionGate(permission: string): boolean {
const access = useAtomValue(myAccessAtom);
return canPerform(access, permission);
}

View file

@ -303,110 +303,16 @@ export const AUTO_INDEX_CONNECTOR_TYPES = new Set<string>(Object.keys(AUTO_INDEX
// `lib/posthog/events.ts` or per-connector tracking code.
// ============================================================================
export type ConnectorTelemetryGroup = "oauth" | "composio" | "crawler" | "other" | "unknown";
export interface ConnectorTelemetryMeta {
connector_type: string;
connector_title: string;
connector_group: ConnectorTelemetryGroup;
is_oauth: boolean;
}
const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap<string, ConnectorTelemetryMeta> = (() => {
const map = new Map<string, ConnectorTelemetryMeta>();
for (const c of OAUTH_CONNECTORS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "oauth",
is_oauth: true,
});
}
for (const c of COMPOSIO_CONNECTORS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "composio",
is_oauth: true,
});
}
for (const c of CRAWLERS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "crawler",
is_oauth: false,
});
}
for (const c of OTHER_CONNECTORS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "other",
is_oauth: false,
});
}
return map;
})();
/**
* Returns telemetry metadata for a connector_type, or a minimal "unknown"
* record so tracking never no-ops for connectors that exist in the backend
* but were forgotten in the UI registry.
*/
export function getConnectorTelemetryMeta(connectorType: string): ConnectorTelemetryMeta {
const hit = CONNECTOR_TELEMETRY_REGISTRY.get(connectorType);
if (hit) return hit;
return {
connector_type: connectorType,
connector_title: connectorType,
connector_group: "unknown",
is_oauth: false,
};
}
// =============================================================================
// REAUTH ENDPOINTS
// =============================================================================
/**
* Legacy (non-MCP) OAuth reauth endpoints, keyed by connector type.
* These are used for connectors that were NOT created via MCP OAuth.
*/
export const LEGACY_REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
[EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/connector/reauth",
[EnumConnectorName.JIRA_CONNECTOR]: "/api/v1/auth/jira/connector/reauth",
[EnumConnectorName.NOTION_CONNECTOR]: "/api/v1/auth/notion/connector/reauth",
[EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/google/drive/connector/reauth",
[EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: "/api/v1/auth/google/gmail/connector/reauth",
[EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/google/calendar/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.ONEDRIVE_CONNECTOR]: "/api/v1/auth/onedrive/connector/reauth",
[EnumConnectorName.DROPBOX_CONNECTOR]: "/api/v1/auth/dropbox/connector/reauth",
[EnumConnectorName.CONFLUENCE_CONNECTOR]: "/api/v1/auth/confluence/connector/reauth",
[EnumConnectorName.TEAMS_CONNECTOR]: "/api/v1/auth/teams/connector/reauth",
[EnumConnectorName.DISCORD_CONNECTOR]: "/api/v1/auth/discord/connector/reauth",
};
/**
* Resolve the reauth endpoint for a connector.
*
* MCP OAuth connectors (those with ``config.mcp_service``) dynamically build
* the URL from the service key. Legacy OAuth connectors fall back to the
* static ``LEGACY_REAUTH_ENDPOINTS`` map.
*/
export function getReauthEndpoint(connector: SearchSourceConnector): string | undefined {
const mcpService = connector.config?.mcp_service as string | undefined;
if (mcpService) {
return `/api/v1/auth/mcp/${mcpService}/connector/reauth`;
}
return LEGACY_REAUTH_ENDPOINTS[connector.connector_type];
}
// Telemetry types & helpers are now defined in `@/lib/connector-telemetry`.
// Re-exported here for backward compatibility with existing imports.
export type {
ConnectorTelemetryGroup,
ConnectorTelemetryMeta,
} from "@/lib/connector-telemetry";
export {
getConnectorTelemetryMeta,
getReauthEndpoint,
} from "@/lib/connector-telemetry";
// Re-export IndexingConfigState from schemas for backward compatibility
export type { IndexingConfigState } from "./connector-popup.schemas";

View file

@ -14,7 +14,9 @@ import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-quer
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { searchSourceConnector } from "@/contracts/types/connector.types";
import { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types/oauth.types";
import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import {
trackConnectorConnected,
trackConnectorDeleted,
@ -36,15 +38,12 @@ import {
OAUTH_CONNECTORS,
OTHER_CONNECTORS,
} from "../constants/connector-constants";
import {
dateRangeSchema,
frequencyMinutesSchema,
parseOAuthAuthResponse,
validateIndexingConfigState,
} from "../constants/connector-popup.schemas";
import { BACKEND_URL } from "@/lib/env-config";
const OAUTH_RESULT_COOKIE = "connector_oauth_result";
function readOAuthResultCookie(): string | null {
const match = document.cookie
@ -211,17 +210,8 @@ export const useConnectorDialog = () => {
if (!raw || !searchSpaceId) return;
clearOAuthResultCookie();
let result: {
success: string | null;
error: string | null;
connector: string | null;
connectorId: string | null;
};
try {
result = JSON.parse(raw);
} catch {
return;
}
const result = parseOAuthCallbackResult(raw);
if (!result) return;
if (result.error) {
const oauthConnector = result.connector

View file

@ -105,7 +105,7 @@ import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events";
import { slideoutOpenedTickAtom } from "@/lib/layout-events";
import { cn } from "@/lib/utils";
const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs";
@ -478,15 +478,18 @@ const Composer: FC = () => {
editorRef.current?.focus();
}, [isDesktop, showDocumentPopover, showPromptPicker, threadId]);
// Close document picker when a slide-out panel (inbox, etc.) opens.
// Close document picker when a sidebar slide-out panel (inbox, etc.) opens.
// React only on changes to the tick — comparing against the previously-seen
// value preserves the one-shot semantics of the prior window-event approach
// (no retroactive close on mount if a panel had already opened earlier).
const slideoutOpenedTick = useAtomValue(slideoutOpenedTickAtom);
const lastSeenSlideoutTickRef = useRef(slideoutOpenedTick);
useEffect(() => {
const handler = () => {
setShowDocumentPopover(false);
setMentionQuery("");
};
window.addEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler);
return () => window.removeEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler);
}, []);
if (lastSeenSlideoutTickRef.current === slideoutOpenedTick) return;
lastSeenSlideoutTickRef.current = slideoutOpenedTick;
setShowDocumentPopover(false);
setMentionQuery("");
}, [slideoutOpenedTick]);
// Sync editor text into assistant-ui's composer and mirror the chip
// atom from the editor's reported ``docs``. The editor is the

View file

@ -1,9 +1,10 @@
"use client";
import { useSetAtom } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect } from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events";
import { slideoutOpenedTickAtom } from "@/lib/layout-events";
interface SidebarSlideOutPanelProps {
open: boolean;
@ -29,12 +30,13 @@ export function SidebarSlideOutPanel({
children,
}: SidebarSlideOutPanelProps) {
const isMobile = useIsMobile();
const bumpSlideoutOpenedTick = useSetAtom(slideoutOpenedTickAtom);
useEffect(() => {
if (open) {
window.dispatchEvent(new Event(SLIDEOUT_PANEL_OPENED_EVENT));
bumpSlideoutOpenedTick((tick) => tick + 1);
}
}, [open]);
}, [open, bumpSlideoutOpenedTick]);
const handleEscape = useCallback(
(e: KeyboardEvent) => {

View file

@ -1,19 +1,19 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { AlertTriangle, Info } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { Spinner } from "../ui/spinner";
import { BACKEND_URL } from "@/lib/env-config";
interface PromptConfigManagerProps {
searchSpaceId: number;
@ -23,15 +23,17 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
const {
data: searchSpace,
isLoading: loading,
refetch: fetchSearchSpace,
} = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,
});
const { mutateAsync: updateSearchSpace, isPending: isSaving } = useAtomValue(
updateSearchSpaceMutationAtom
);
const [customInstructions, setCustomInstructions] = useState("");
const [saving, setSaving] = useState(false);
const hasSearchSpace = !!searchSpace;
const searchSpaceInstructions = searchSpace?.qna_custom_instructions;
@ -48,34 +50,16 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
const handleSave = async () => {
try {
setSaving(true);
const payload = {
qna_custom_instructions: customInstructions.trim() || "",
};
const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || "Failed to save system instructions");
}
await updateSearchSpace({
id: searchSpaceId,
data: { qna_custom_instructions: customInstructions.trim() || "" },
});
toast.success("System instructions saved successfully");
await fetchSearchSpace();
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Failed to save system instructions";
console.error("Error saving system instructions:", error);
toast.error(error instanceof Error ? error.message : "Failed to save system instructions");
} finally {
setSaving(false);
toast.error(message);
}
};
@ -184,11 +168,11 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
<Button
type="submit"
variant="outline"
disabled={!hasChanges || saving}
disabled={!hasChanges || isSaving}
className="gap-2 bg-white text-black hover:bg-accent hover:text-accent-foreground dark:bg-white dark:text-black"
>
{saving ? <Spinner size="sm" /> : null}
{saving ? "Saving" : "Save Instructions"}
{isSaving ? <Spinner size="sm" /> : null}
{isSaving ? "Saving" : "Save Instructions"}
</Button>
</div>
</form>

View file

@ -26,7 +26,7 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
import { canPerform, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms";
import {
createRoleMutationAtom,
@ -257,11 +257,7 @@ export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) {
const { data: access = null } = useAtomValue(myAccessAtom);
const hasPermission = useCallback(
(permission: string) => {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes(permission) ?? false;
},
(permission: string) => canPerform(access, permission),
[access]
);

View file

@ -0,0 +1,31 @@
import { z } from "zod";
export const OAUTH_RESULT_COOKIE = "connector_oauth_result";
/**
* Schema for the payload written to the `connector_oauth_result` cookie by the
* OAuth callback route and read back by the connector dialog hook.
*/
export const oauthCallbackResultSchema = z.object({
success: z.string().nullable(),
error: z.string().nullable(),
connector: z.string().nullable(),
connectorId: z.string().nullable(),
});
export type OAuthCallbackResult = z.infer<typeof oauthCallbackResultSchema>;
/**
* Safely decode and validate the OAuth callback cookie value. Returns `null`
* when the value is not valid JSON or does not match the expected shape.
*/
export function parseOAuthCallbackResult(raw: string): OAuthCallbackResult | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
const result = oauthCallbackResultSchema.safeParse(parsed);
return result.success ? result.data : null;
}

View file

@ -0,0 +1,149 @@
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import {
OAUTH_CONNECTORS,
COMPOSIO_CONNECTORS,
CRAWLERS,
OTHER_CONNECTORS,
} from "@/components/assistant-ui/connector-popup/constants/connector-constants";
// =============================================================================
// Connector Telemetry Types & Registry
// =============================================================================
export type ConnectorTelemetryGroup =
| "oauth"
| "composio"
| "crawler"
| "other"
| "unknown";
export interface ConnectorTelemetryMeta {
connector_type: string;
connector_title: string;
connector_group: ConnectorTelemetryGroup;
is_oauth: boolean;
}
/**
* Single source of truth for "what does this connector_type look like in
* analytics?". Any connector added to the lists above is automatically
* picked up here, so adding a new integration does NOT require touching
* `lib/posthog/events.ts` or per-connector tracking code.
*/
const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap<
string,
ConnectorTelemetryMeta
> = (() => {
const map = new Map<string, ConnectorTelemetryMeta>();
for (const c of OAUTH_CONNECTORS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "oauth",
is_oauth: true,
});
}
for (const c of COMPOSIO_CONNECTORS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "composio",
is_oauth: true,
});
}
for (const c of CRAWLERS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "crawler",
is_oauth: false,
});
}
for (const c of OTHER_CONNECTORS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "other",
is_oauth: false,
});
}
return map;
})();
/**
* Returns telemetry metadata for a connector_type, or a minimal "unknown"
* record so tracking never no-ops for connectors that exist in the backend
* but were forgotten in the UI registry.
*/
export function getConnectorTelemetryMeta(
connectorType: string,
): ConnectorTelemetryMeta {
const hit = CONNECTOR_TELEMETRY_REGISTRY.get(connectorType);
if (hit) return hit;
return {
connector_type: connectorType,
connector_title: connectorType,
connector_group: "unknown",
is_oauth: false,
};
}
// =============================================================================
// Reauth Endpoint Resolution
// =============================================================================
/**
* Legacy (non-MCP) OAuth reauth endpoints, keyed by connector type.
* These are used for connectors that were NOT created via MCP OAuth.
*/
const LEGACY_REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
[EnumConnectorName.LINEAR_CONNECTOR]:
"/api/v1/auth/linear/connector/reauth",
[EnumConnectorName.JIRA_CONNECTOR]:
"/api/v1/auth/jira/connector/reauth",
[EnumConnectorName.NOTION_CONNECTOR]:
"/api/v1/auth/notion/connector/reauth",
[EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]:
"/api/v1/auth/google/drive/connector/reauth",
[EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]:
"/api/v1/auth/google/gmail/connector/reauth",
[EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]:
"/api/v1/auth/google/calendar/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]:
"/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]:
"/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]:
"/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.ONEDRIVE_CONNECTOR]:
"/api/v1/auth/onedrive/connector/reauth",
[EnumConnectorName.DROPBOX_CONNECTOR]:
"/api/v1/auth/dropbox/connector/reauth",
[EnumConnectorName.CONFLUENCE_CONNECTOR]:
"/api/v1/auth/confluence/connector/reauth",
[EnumConnectorName.TEAMS_CONNECTOR]:
"/api/v1/auth/teams/connector/reauth",
[EnumConnectorName.DISCORD_CONNECTOR]:
"/api/v1/auth/discord/connector/reauth",
};
/**
* Resolve the reauth endpoint for a connector.
*
* MCP OAuth connectors (those with ``config.mcp_service``) dynamically build
* the URL from the service key. Legacy OAuth connectors fall back to the
* static ``LEGACY_REAUTH_ENDPOINTS`` map.
*/
export function getReauthEndpoint(
connector: SearchSourceConnector,
): string | undefined {
const mcpService = connector.config?.mcp_service as string | undefined;
if (mcpService) {
return `/api/v1/auth/mcp/${mcpService}/connector/reauth`;
}
return LEGACY_REAUTH_ENDPOINTS[connector.connector_type];
}

View file

@ -1 +1,11 @@
export const SLIDEOUT_PANEL_OPENED_EVENT = "slideout-panel-opened";
import { atom } from "jotai";
/**
* Tick counter that increments each time a sidebar slide-out panel opens.
* Consumers read this with `useAtomValue` and store the last-seen value in
* a ref so the effect fires only when the tick changes. This preserves the
* one-shot semantics of the previous window-event implementation: a
* subscriber that mounts after a panel has already opened does not
* retroactively re-trigger.
*/
export const slideoutOpenedTickAtom = atom(0);

View file

@ -1,5 +1,5 @@
import posthog from "posthog-js";
import { getConnectorTelemetryMeta } from "@/components/assistant-ui/connector-popup/constants/connector-constants";
import type { ConnectorTelemetryMeta } from "@/lib/connector-telemetry";
import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry";
import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat-error-classifier";
/**