fix: make ai features more discoverable (#2305)

Previously you had to have explicit knowledge of the
feature and enable it in order to use AI assisted field
detection.

This surfaces it by having a secondary dialog prompting
for enablement.

Also includes a fix for CC recipients not getting marked
as signed in weird edge cases.
This commit is contained in:
Lucas Smith
2025-12-09 15:30:48 +11:00
committed by GitHub
parent 0387f3c20a
commit 4d3d1b8d14
5 changed files with 260 additions and 50 deletions
@@ -0,0 +1,141 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useCurrentTeam } from '~/providers/team';
type AiFeaturesEnableDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onEnabled: () => void;
};
export const AiFeaturesEnableDialog = ({
open,
onOpenChange,
onEnabled,
}: AiFeaturesEnableDialogProps) => {
const { t } = useLingui();
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const isTeamAdmin = team.currentTeamRole === TeamMemberRole.ADMIN;
const isOrganisationAdmin = organisation.currentOrganisationRole === OrganisationMemberRole.ADMIN;
const canEnableAiFeatures = isTeamAdmin || isOrganisationAdmin;
const [error, setError] = useState<string | null>(null);
const { mutateAsync: updateTeamSettings, isPending: isUpdatingTeamSettings } =
trpc.team.settings.update.useMutation();
const { mutateAsync: updateOrganisationSettings, isPending: isUpdatingOrganisationSettings } =
trpc.organisation.settings.update.useMutation();
const isSubmitting = isUpdatingTeamSettings || isUpdatingOrganisationSettings;
const onEnableClick = async () => {
if (!canEnableAiFeatures) {
return;
}
setError(null);
try {
if (isTeamAdmin) {
await updateTeamSettings({
teamId: team.id,
data: { aiFeaturesEnabled: true },
});
} else {
await updateOrganisationSettings({
organisationId: organisation.id,
data: { aiFeaturesEnabled: true },
});
}
onEnabled();
onOpenChange(false);
} catch (err) {
console.error('Failed to enable AI features', err);
setError(
err instanceof Error
? err.message
: t`We couldn't enable AI features right now. Please try again.`,
);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
<Trans>Enable AI features</Trans>
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<Trans>
Turn on AI detection to automatically find recipients and fields in your documents. AI
providers do not retain your data for training.
</Trans>
</p>
<Alert variant="neutral">
<AlertDescription>
<Trans>
Your document content will be sent securely to our AI provider solely for detection
and will not be stored or used for training.
</Trans>
</AlertDescription>
</Alert>
{canEnableAiFeatures ? (
<p className="text-sm text-muted-foreground">
<Trans>
You're an admin. You can enable AI features for this team right away. Everyone on
the team will see AI detection once enabled.
</Trans>
</p>
) : (
<p className="text-sm text-muted-foreground">
<Trans>
AI features are disabled for your team. Please ask your team owner or organisation
owner to enable them.
</Trans>
</p>
)}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
<Trans>Close</Trans>
</Button>
{canEnableAiFeatures ? (
<Button type="button" onClick={() => void onEnableClick()} loading={isSubmitting}>
<Trans>Enable AI features</Trans>
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, SparklesIcon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { Link, useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@@ -34,6 +34,7 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
@@ -81,6 +82,8 @@ export const EnvelopeEditorFieldsPage = () => {
const { _ } = useLingui();
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator();
const selectedField = useMemo(
() => structuredClone(editorFields.selectedField),
@@ -135,6 +138,22 @@ export const EnvelopeEditorFieldsPage = () => {
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
}, []);
const onDetectClick = () => {
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
return;
}
setIsAiFieldDialogOpen(true);
};
const onAiFeaturesEnabled = () => {
void revalidate().then(() => {
setIsAiEnableDialogOpen(false);
setIsAiFieldDialogOpen(true);
});
};
return (
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
@@ -230,34 +249,36 @@ export const EnvelopeEditorFieldsPage = () => {
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
{team.preferences.aiFeaturesEnabled && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={() => setIsAiFieldDialogOpen(true)}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
</>
)}
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</section>
{/* Field details section. */}
@@ -14,7 +14,7 @@ import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { useSearchParams } from 'react-router';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual, prop, sortBy } from 'remeda';
import { z } from 'zod';
@@ -63,6 +63,7 @@ import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
@@ -97,11 +98,19 @@ export const EnvelopeEditorRecipientForm = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
// AI recipient detection dialog state
const [isAiDialogOpen, setIsAiDialogOpen] = useState(() => searchParams.get('ai') === 'true');
const { revalidate } = useRevalidator();
const onAiDialogOpenChange = (open: boolean) => {
if (open && !team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
setIsAiDialogOpen(false);
return;
}
setIsAiDialogOpen(open);
if (!open && searchParams.get('ai') === 'true') {
@@ -118,6 +127,22 @@ export const EnvelopeEditorRecipientForm = () => {
}
};
const onDetectRecipientsClick = () => {
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
return;
}
setIsAiDialogOpen(true);
};
const onAiFeaturesEnabled = () => {
void revalidate().then(() => {
setIsAiEnableDialogOpen(false);
setIsAiDialogOpen(true);
});
};
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const initialId = useId();
@@ -632,25 +657,27 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
<div className="flex flex-row items-center space-x-2">
{team.preferences.aiFeaturesEnabled && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
size="sm"
disabled={isSubmitting}
onClick={() => setIsAiDialogOpen(true)}
>
<SparklesIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
size="sm"
disabled={isSubmitting}
onClick={onDetectRecipientsClick}
>
<SparklesIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<TooltipContent>
{team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
</TooltipContent>
</Tooltip>
)}
) : (
<Trans>Enable AI detection</Trans>
)}
</TooltipContent>
</Tooltip>
<Button
variant="outline"
@@ -1107,6 +1134,12 @@ export const EnvelopeEditorRecipientForm = () => {
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</CardContent>
</Card>
);
@@ -1,7 +1,7 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, SigningStatus } from '@prisma/client';
import { EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
@@ -86,7 +86,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
)}
</div>
<div className="text-muted-foreground mt-4 text-sm">
<div className="mt-4 text-sm text-muted-foreground">
<div>
<Trans>Created on</Trans>: {i18n.date(envelope.createdAt, DateTime.DATETIME_MED)}
</div>
@@ -112,7 +112,8 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
disabled={envelope.recipients.some(
(recipient) =>
recipient.signingStatus !== SigningStatus.SIGNED &&
recipient.signingStatus !== SigningStatus.REJECTED,
recipient.signingStatus !== SigningStatus.REJECTED &&
recipient.role !== RecipientRole.CC,
)}
onClick={() => resealDocument({ id: envelope.id })}
>
@@ -92,9 +92,23 @@ export const run = async ({
teamId: envelope.teamId,
});
// Ensure all CC recipients are marked as signed
await prisma.recipient.updateMany({
where: {
envelopeId: envelope.id,
role: RecipientRole.CC,
},
data: {
signingStatus: SigningStatus.SIGNED,
},
});
const isComplete =
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
envelope.recipients.every(
(recipient) =>
recipient.signingStatus === SigningStatus.SIGNED || recipient.role === RecipientRole.CC,
);
if (!isComplete) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {