diff --git a/apps/api/src/email/templates/invite-member.tsx b/apps/api/src/email/templates/invite-member.tsx index 3f3f5524c2..7141c765ae 100644 --- a/apps/api/src/email/templates/invite-member.tsx +++ b/apps/api/src/email/templates/invite-member.tsx @@ -18,9 +18,10 @@ interface Props { organizationName: string; inviteLink: string; email?: string; + portalLink?: string; } -export const InviteEmail = ({ organizationName, inviteLink, email }: Props) => { +export const InviteEmail = ({ organizationName, inviteLink, email, portalLink }: Props) => { return ( @@ -69,6 +70,21 @@ export const InviteEmail = ({ organizationName, inviteLink, email }: Props) => { + {portalLink && ( + <> + + You also have access to the {organizationName} Employee Portal for + completing compliance tasks like signing policies and security training. + Once you've accepted your invite above, you can access the portal at: + + + + {portalLink} + + + + )} +
{email && (
diff --git a/apps/api/src/people/people-invite.service.spec.ts b/apps/api/src/people/people-invite.service.spec.ts index 420c2c8760..0f90868357 100644 --- a/apps/api/src/people/people-invite.service.spec.ts +++ b/apps/api/src/people/people-invite.service.spec.ts @@ -1,9 +1,17 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { PeopleInviteService } from './people-invite.service'; import { TimelinesService } from '../timelines/timelines.service'; jest.mock('@db', () => ({ + BackgroundCheckStatus: { + invited: 'invited', + in_progress: 'in_progress', + in_review: 'in_review', + completed: 'completed', + completed_with_flags: 'completed_with_flags', + failed: 'failed', + cancelled: 'cancelled', + }, db: { organization: { findUnique: jest.fn(), @@ -23,6 +31,64 @@ jest.mock('@db', () => ({ employeeTrainingVideoCompletion: { createMany: jest.fn(), }, + frameworkInstance: { + findFirst: jest.fn(), + }, + organizationRole: { + findMany: jest.fn().mockResolvedValue([]), + }, + }, +})); + +jest.mock('@trycompai/auth', () => ({ + BUILT_IN_ROLE_PERMISSIONS: { + owner: { + organization: ['read', 'update', 'delete'], + member: ['create', 'read', 'update', 'delete'], + app: ['read'], + }, + admin: { + organization: ['read', 'update'], + member: ['create', 'read', 'update', 'delete'], + app: ['read'], + }, + auditor: { + member: ['create', 'read'], + app: ['read'], + }, + employee: { + policy: ['read'], + portal: ['read', 'update'], + }, + contractor: { + policy: ['read'], + portal: ['read', 'update'], + }, + }, + BUILT_IN_ROLE_OBLIGATIONS: { + owner: { compliance: true }, + admin: {}, + auditor: {}, + employee: { compliance: true }, + contractor: { compliance: true }, + }, + isRestrictedRole: (role: string) => + role === 'employee' || role === 'contractor', + parseRoleObligations: (value: unknown) => { + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } + }, + parseRolePermissions: (value: unknown) => { + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } }, })); @@ -30,8 +96,20 @@ jest.mock('../email/trigger-email', () => ({ triggerEmail: jest.fn().mockResolvedValue({ id: 'trigger_123' }), })); +jest.mock('../frameworks/frameworks-timeline.helper', () => ({ + checkAutoCompletePhases: jest.fn().mockResolvedValue(undefined), +})); + +const mockInviteEmail = jest.fn().mockReturnValue('mocked-app-element'); jest.mock('../email/templates/invite-member', () => ({ - InviteEmail: jest.fn().mockReturnValue('mocked-react-element'), + InviteEmail: (...args: unknown[]) => mockInviteEmail(...args), +})); + +const mockInvitePortalEmail = jest + .fn() + .mockReturnValue('mocked-portal-element'); +jest.mock('@trycompai/email', () => ({ + InvitePortalEmail: (...args: unknown[]) => mockInvitePortalEmail(...args), })); import { db } from '@db'; @@ -68,17 +146,17 @@ describe('PeopleInviteService', () => { callerRole: 'admin,owner', }; - it('should throw ForbiddenException for unauthorized roles', async () => { - await expect( - service.inviteMembers({ - ...baseParams, - callerRole: 'employee', - invites: [{ email: 'test@example.com', roles: ['employee'] }], - }), - ).rejects.toThrow(ForbiddenException); + it('should return error for employee caller trying to invite', async () => { + const results = await service.inviteMembers({ + ...baseParams, + callerRole: 'employee', + invites: [{ email: 'test@example.com', roles: ['employee'] }], + }); + + expect(results[0].success).toBe(false); }); - it('should restrict auditors to only invite auditors', async () => { + it('should restrict auditors from assigning privileged roles', async () => { const results = await service.inviteMembers({ ...baseParams, callerRole: 'auditor', @@ -86,34 +164,33 @@ describe('PeopleInviteService', () => { }); expect(results[0].success).toBe(false); - expect(results[0].error).toContain('Auditors can only invite'); + expect(results[0].error).toContain('privileged roles'); }); - it('should allow auditors to invite other auditors', async () => { - // inviteWithCheck path: user doesn't exist → create invitation - (mockDb.user.findFirst as jest.Mock).mockResolvedValue(null); + it('should allow auditors to invite restricted roles', async () => { (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ name: 'Test Org', }); - (mockDb.invitation.create as jest.Mock).mockResolvedValue({ - id: 'inv_auditor', + (mockDb.user.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.user.create as jest.Mock).mockResolvedValue({ + id: 'usr_emp', + email: 'emp@example.com', }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.member.create as jest.Mock).mockResolvedValue({ + id: 'mem_emp', + }); + ( + mockDb.employeeTrainingVideoCompletion.createMany as jest.Mock + ).mockResolvedValue({ count: 5 }); const results = await service.inviteMembers({ ...baseParams, callerRole: 'auditor', - invites: [{ email: 'auditor@example.com', roles: ['auditor'] }], + invites: [{ email: 'emp@example.com', roles: ['employee'] }], }); expect(results[0].success).toBe(true); - expect(mockDb.invitation.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - email: 'auditor@example.com', - role: 'auditor', - }), - }), - ); }); it('should add employee without invitation for employee/contractor roles', async () => { @@ -301,5 +378,130 @@ describe('PeopleInviteService', () => { }), ); }); + + describe('email flow by role combination', () => { + function setupNewUserInvite() { + (mockDb.user.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + name: 'Test Org', + }); + (mockDb.invitation.create as jest.Mock).mockResolvedValue({ + id: 'inv_new', + }); + } + + it('admin + employee with portal checked: sends single app email with portal link', async () => { + setupNewUserInvite(); + + const results = await service.inviteMembers({ + ...baseParams, + invites: [ + { + email: 'both@example.com', + roles: ['admin', 'employee'], + sendPortalEmail: true, + }, + ], + }); + + expect(results[0].success).toBe(true); + expect(mockTriggerEmail).toHaveBeenCalledTimes(1); + expect(mockInviteEmail).toHaveBeenCalledWith( + expect.objectContaining({ + organizationName: 'Test Org', + portalLink: expect.stringContaining('org_123'), + }), + ); + expect(mockInvitePortalEmail).not.toHaveBeenCalled(); + }); + + it('employee only with portal checked: sends portal-only email', async () => { + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + name: 'Test Org', + }); + (mockDb.user.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.user.create as jest.Mock).mockResolvedValue({ + id: 'usr_emp', + email: 'emp@example.com', + }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.member.create as jest.Mock).mockResolvedValue({ + id: 'mem_emp', + }); + ( + mockDb.employeeTrainingVideoCompletion.createMany as jest.Mock + ).mockResolvedValue({ count: 5 }); + + const results = await service.inviteMembers({ + ...baseParams, + invites: [ + { + email: 'emp@example.com', + roles: ['employee'], + sendPortalEmail: true, + }, + ], + }); + + expect(results[0].success).toBe(true); + expect(mockTriggerEmail).toHaveBeenCalledTimes(1); + expect(mockInvitePortalEmail).toHaveBeenCalledWith( + expect.objectContaining({ + organizationName: 'Test Org', + email: 'emp@example.com', + }), + ); + expect(mockInviteEmail).not.toHaveBeenCalled(); + }); + + it('admin only (no portal): sends app email without portal link', async () => { + setupNewUserInvite(); + + const results = await service.inviteMembers({ + ...baseParams, + invites: [ + { + email: 'admin@example.com', + roles: ['admin'], + sendPortalEmail: false, + }, + ], + }); + + expect(results[0].success).toBe(true); + expect(mockTriggerEmail).toHaveBeenCalledTimes(1); + expect(mockInviteEmail).toHaveBeenCalledWith( + expect.objectContaining({ organizationName: 'Test Org' }), + ); + expect(mockInviteEmail).toHaveBeenCalledWith( + expect.not.objectContaining({ + portalLink: expect.anything(), + }), + ); + expect(mockInvitePortalEmail).not.toHaveBeenCalled(); + }); + + it('admin with portal checked but no compliance obligation: sends app email without portal', async () => { + setupNewUserInvite(); + + const results = await service.inviteMembers({ + ...baseParams, + invites: [ + { + email: 'admin2@example.com', + roles: ['admin'], + sendPortalEmail: true, + }, + ], + }); + + expect(results[0].success).toBe(true); + expect(mockTriggerEmail).toHaveBeenCalledTimes(1); + expect(mockInviteEmail).toHaveBeenCalledWith( + expect.objectContaining({ organizationName: 'Test Org' }), + ); + expect(mockInvitePortalEmail).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/apps/api/src/people/people-invite.service.ts b/apps/api/src/people/people-invite.service.ts index 6cd24fd5c7..2c0c508a40 100644 --- a/apps/api/src/people/people-invite.service.ts +++ b/apps/api/src/people/people-invite.service.ts @@ -66,6 +66,10 @@ export class PeopleInviteService { ); const shouldSendPortalEmail = !!invite.sendPortalEmail && hasCompliance; + const shouldSendAppEmail = await this.rolesHaveAppAccess( + invite.roles, + organizationId, + ); if (isStrictlyEmployee) { const result = await this.addEmployeeWithoutInvite( @@ -80,13 +84,14 @@ export class PeopleInviteService { emailSent: result.emailSent, }); } else { - await this.inviteWithCheck( + await this.inviteWithCheck({ email, - invite.roles, + roles: invite.roles, organizationId, - callerUserId, - shouldSendPortalEmail, - ); + currentUserId: callerUserId, + sendPortalEmail: shouldSendPortalEmail, + sendAppEmail: shouldSendAppEmail, + }); results.push({ email: invite.email, success: true }); } } catch (error) { @@ -211,13 +216,23 @@ export class PeopleInviteService { return { emailSent }; } - private async inviteWithCheck( - email: string, - roles: string[], - organizationId: string, - currentUserId: string, - sendPortalEmail?: boolean, - ): Promise { + private async inviteWithCheck(params: { + email: string; + roles: string[]; + organizationId: string; + currentUserId: string; + sendPortalEmail?: boolean; + sendAppEmail?: boolean; + }): Promise { + const { + email, + roles, + organizationId, + currentUserId, + sendPortalEmail, + sendAppEmail, + } = params; + const existingUser = await db.user.findFirst({ where: { email: { equals: email, mode: 'insensitive' } }, }); @@ -237,19 +252,18 @@ export class PeopleInviteService { return; } - // Active member — send invitation email - await this.sendInvitationEmailToExistingMember( + await this.sendInvitationEmailToExistingMember({ email, roles, organizationId, - currentUserId, + inviterId: currentUserId, sendPortalEmail, - ); + sendAppEmail, + }); return; } } - // User doesn't exist or isn't a member — create invitation and send email const roleString = roles.join(','); const organization = await db.organization.findUnique({ where: { id: organizationId }, @@ -271,34 +285,33 @@ export class PeopleInviteService { }, }); - if (sendPortalEmail) { - const inviteLink = this.buildPortalUrl(organizationId); - await triggerEmail({ - to: email, - subject: `You've been invited to join ${organization.name} on Comp AI`, - react: InvitePortalEmail({ - organizationName: organization.name, - inviteLink, - email, - }), - }); - } else { - const inviteLink = this.buildInviteLink(invitation.id); - await triggerEmail({ - to: email, - subject: `You've been invited to join ${organization.name} on Comp AI`, - react: InviteEmail({ organizationName: organization.name, inviteLink }), - }); - } + await this.sendInviteEmails({ + email, + organizationName: organization.name, + sendPortalEmail, + sendAppEmail, + portalLink: this.buildPortalUrl(organizationId), + appLink: this.buildInviteLink(invitation.id), + }); } - private async sendInvitationEmailToExistingMember( - email: string, - roles: string[], - organizationId: string, - inviterId: string, - sendPortalEmail?: boolean, - ): Promise { + private async sendInvitationEmailToExistingMember(params: { + email: string; + roles: string[]; + organizationId: string; + inviterId: string; + sendPortalEmail?: boolean; + sendAppEmail?: boolean; + }): Promise { + const { + email, + roles, + organizationId, + inviterId, + sendPortalEmail, + sendAppEmail, + } = params; + const organization = await db.organization.findUnique({ where: { id: organizationId }, select: { name: true }, @@ -319,25 +332,14 @@ export class PeopleInviteService { }, }); - if (sendPortalEmail) { - const inviteLink = this.buildPortalUrl(organizationId); - await triggerEmail({ - to: email.toLowerCase(), - subject: `You've been invited to join ${organization.name} on Comp AI`, - react: InvitePortalEmail({ - organizationName: organization.name, - inviteLink, - email: email.toLowerCase(), - }), - }); - } else { - const inviteLink = this.buildInviteLink(invitation.id); - await triggerEmail({ - to: email.toLowerCase(), - subject: `You've been invited to join ${organization.name} on Comp AI`, - react: InviteEmail({ organizationName: organization.name, inviteLink }), - }); - } + await this.sendInviteEmails({ + email: email.toLowerCase(), + organizationName: organization.name, + sendPortalEmail, + sendAppEmail, + portalLink: this.buildPortalUrl(organizationId), + appLink: this.buildInviteLink(invitation.id), + }); } async resendPortalInvite(params: { @@ -407,6 +409,81 @@ export class PeopleInviteService { }); } + private async sendInviteEmails(params: { + email: string; + organizationName: string; + sendPortalEmail?: boolean; + sendAppEmail?: boolean; + portalLink: string; + appLink: string; + }): Promise { + const { + email, + organizationName, + sendPortalEmail, + sendAppEmail, + portalLink, + appLink, + } = params; + + if (sendAppEmail) { + await triggerEmail({ + to: email, + subject: `You've been invited to join ${organizationName} on Comp AI`, + react: InviteEmail({ + organizationName, + inviteLink: appLink, + portalLink: sendPortalEmail ? portalLink : undefined, + }), + }); + } else if (sendPortalEmail) { + await triggerEmail({ + to: email, + subject: `You've been invited to join ${organizationName} on Comp AI`, + react: InvitePortalEmail({ + organizationName, + inviteLink: portalLink, + email, + }), + }); + } else { + await triggerEmail({ + to: email, + subject: `You've been invited to join ${organizationName} on Comp AI`, + react: InviteEmail({ + organizationName, + inviteLink: appLink, + }), + }); + } + } + + private async rolesHaveAppAccess( + roles: string[], + organizationId: string, + ): Promise { + for (const role of roles) { + if (BUILT_IN_ROLE_PERMISSIONS[role]?.app) return true; + } + + const customRoleNames = roles.filter( + (r) => !BUILT_IN_ROLE_PERMISSIONS[r], + ); + if (customRoleNames.length === 0) return false; + + const customRoles = await db.organizationRole.findMany({ + where: { + organizationId, + name: { in: customRoleNames }, + }, + select: { permissions: true }, + }); + + return customRoles.some((role) => + parseRolePermissions(role.permissions)?.app, + ); + } + private async rolesHaveComplianceObligation( roles: string[], organizationId: string, diff --git a/apps/app/src/components/risks/treatment-plan/DescriptionEditor.tsx b/apps/app/src/components/risks/treatment-plan/DescriptionEditor.tsx index becb4fd583..64a4f51a64 100644 --- a/apps/app/src/components/risks/treatment-plan/DescriptionEditor.tsx +++ b/apps/app/src/components/risks/treatment-plan/DescriptionEditor.tsx @@ -11,17 +11,10 @@ import { cn } from '@/lib/utils'; interface DescriptionEditorProps { value: string; onSave: (next: string) => Promise; - onRegenerate: () => Promise; - regenerating: boolean; + onRegenerate?: () => Promise; + regenerating?: boolean; disabled?: boolean; - /** - * The trigger.dev run handle for an in-flight regeneration. When set, the - * editor subscribes via `useRealtimeRun`, renders status-specific progress - * copy, and notifies the parent via `onRegenSettled` when the run reaches - * a terminal state. Null/undefined while no regen is active. - */ regenRun?: { runId: string; publicAccessToken: string } | null; - /** Called once the regeneration run terminates (success or failure). */ onRegenSettled?: (result: { success: boolean; reason?: string }) => void; } @@ -34,32 +27,17 @@ const TERMINAL_FAILURE_STATUSES = new Set([ 'TIMED_OUT', ]); -/** - * Cap (in px) for both the markdown preview and the auto-growing textarea. - * Past this height, the body scrolls internally so the Treatment plan column - * stays roughly aligned with the Strategy and Linked Work columns instead - * of pushing the whole row downward when AI emits a long plan. - */ const TEXTAREA_MAX_PX = 480; function regenStatusCopy(status: string | undefined): { headline: string; sub: string } { if (!status || status === 'WAITING_FOR_DEPLOY') { - return { - headline: 'Starting AI scan…', - sub: 'Allocating compute capacity.', - }; + return { headline: 'Starting AI scan…', sub: 'Allocating compute capacity.' }; } if (status === 'QUEUED' || status === 'DELAYED') { - return { - headline: 'Queued — waiting to start…', - sub: 'Your regeneration will begin in a moment.', - }; + return { headline: 'Queued — waiting to start…', sub: 'Your regeneration will begin in a moment.' }; } if (status === 'INTERRUPTED' || status === 'WAITING_TO_RESUME') { - return { - headline: 'Resuming…', - sub: 'Picking up where the run left off.', - }; + return { headline: 'Resuming…', sub: 'Picking up where the run left off.' }; } return { headline: 'AI is drafting your treatment plan…', @@ -82,121 +60,55 @@ export function DescriptionEditor({ regenRun, onRegenSettled, }: DescriptionEditorProps) { - const [draft, setDraft] = useState(value); - const [saving, setSaving] = useState(false); - // Mode: 'preview' renders markdown, 'edit' shows the auto-growing textarea. - // We default to 'edit' when the value is empty (nothing to preview yet) and - // stay in 'edit' when an AI regeneration completes with new content so the - // user immediately sees what was drafted. - const [mode, setMode] = useState<'preview' | 'edit'>( - value.trim().length > 0 ? 'preview' : 'edit', + // null = preview mode (render value directly), string = edit mode (user's draft). + // Initialized to edit mode when value is empty (nothing to preview). + // Parent uses key={strategy} to remount on strategy change, so this + // always initializes from the correct value — no sync effects needed. + const [draft, setDraft] = useState( + value.trim().length > 0 ? null : value, ); + const [saving, setSaving] = useState(false); const textareaRef = useRef(null); - // Resync the draft from upstream `value` ONLY when the user isn't - // actively editing. Without the `mode === 'edit'` guard, a background - // SWR revalidation, AI regeneration, or any other prop change would - // wipe whatever the user was typing. (Cubic finding on PR #2671.) - useEffect(() => { - if (saving) return; - if (mode === 'edit') return; - setDraft(value); - }, [value, saving, mode]); - - // When a fresh value arrives from upstream (regenerate, server update) and - // we're not actively editing, drop back to preview. - useEffect(() => { - if (mode === 'edit' || saving) return; - if (value.trim().length === 0) setMode('edit'); - }, [value, mode, saving]); - - // Regenerate-with-AI bypasses the in-edit guard above. When a regen run - // terminates (`regenRun` flips from set → null), the user explicitly - // asked to overwrite whatever they had — keeping the stale draft and - // requiring a refresh to see the new prose was confusing. - // - // The new prose may already be in `value` at the moment regenRun - // clears (sync write before the parent flips the run handle), or it - // may arrive in a later render after SWR refetches. Both paths are - // handled: - // - // 1. Sync arrival: when regenRun flips set→null, immediately apply - // the current value and force preview. - // 2. Async arrival: capture the value-at-clear-time. The next render - // where `value` differs from the captured snapshot is the AI prose - // landing — apply it, force preview, and clear the latch. - // - // Without (2), a regen that completes BEFORE the SWR refetch would - // sync-apply the OLD value, and the new prose arriving moments later - // would be ignored because the in-edit guard skips resync while - // mode === 'edit'. - const prevRegenRunRef = useRef(regenRun); - const valueAtRegenClearRef = useRef(null); - useEffect(() => { - const wasRunning = prevRegenRunRef.current != null; - const isRunning = regenRun != null; - prevRegenRunRef.current = regenRun; - if (wasRunning && !isRunning) { - // Path 1: sync arrival — value has already updated. - valueAtRegenClearRef.current = value; - setDraft(value); - if (value.trim().length > 0) setMode('preview'); - } - }, [regenRun, value]); - - useEffect(() => { - const captured = valueAtRegenClearRef.current; - if (captured === null) return; - if (value === captured) return; - // Path 2: async arrival — value just changed since regen cleared, - // so this is the AI prose landing. Overwrite even if user is in - // edit mode (they explicitly opted into the overwrite by clicking - // Regenerate). - valueAtRegenClearRef.current = null; - setDraft(value); - if (value.trim().length > 0) setMode('preview'); - }, [value]); + const isEditing = draft !== null; + const displayText = draft ?? value; + const hasValue = value.trim().length > 0; + const isDirty = isEditing && draft.trim() !== value.trim(); + const wordCount = countWords(displayText); + const charCount = displayText.length; - // Auto-grow the textarea to fit content, but cap at TEXTAREA_MAX_PX so a - // long draft doesn't stretch the Treatment plan column past the Strategy - // / Linked Work columns. Internal scroll kicks in past the cap. useLayoutEffect(() => { - if (mode !== 'edit') return; + if (!isEditing) return; const el = textareaRef.current; if (!el) return; el.style.height = 'auto'; const next = Math.max(Math.min(el.scrollHeight, TEXTAREA_MAX_PX), 200); el.style.height = `${next}px`; el.style.overflowY = el.scrollHeight > TEXTAREA_MAX_PX ? 'auto' : 'hidden'; - }, [draft, mode]); - - const isDirty = draft.trim() !== (value ?? '').trim(); - const wordCount = countWords(draft); - const charCount = draft.length; - const hasValue = value.trim().length > 0; + }, [displayText, isEditing]); const handleSave = async () => { if (!isDirty) { - setMode('preview'); + setDraft(null); return; } setSaving(true); try { await onSave(draft.trim()); - setMode('preview'); + setDraft(null); } finally { setSaving(false); } }; - const handleCancelEdit = () => { - setDraft(value); - setMode('preview'); + const handleRegenerate = () => { + setDraft(null); + onRegenerate?.(); }; return (
- {mode === 'preview' && hasValue ? ( + {!isEditing && hasValue ? (
setDraft(e.target.value)} disabled={disabled || saving} placeholder="Describe how this risk is being treated — concrete controls, owners, timelines. Markdown supported." @@ -214,27 +126,29 @@ export function DescriptionEditor({ style={{ resize: 'none', minHeight: 200 }} /> )} -
+
{wordCount} {wordCount === 1 ? 'word' : 'words'} · {charCount}{' '} {charCount === 1 ? 'char' : 'chars'} - - {mode === 'preview' ? ( + {onRegenerate && ( + + )} + {!isEditing ? (