From 8800f03fa389cf1fd3b58f38c89842aeefc03acf Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 24 Mar 2026 22:28:39 -0700 Subject: [PATCH 01/20] improvement(billing): treat past_due state correctly (#3750) * improvement(billing): treat past_due state correctly * toggle fixes * address comments * Fix type errors --- apps/sim/app/api/billing/portal/route.ts | 5 +- apps/sim/app/api/billing/route.ts | 88 +-------------- apps/sim/app/api/billing/switch-plan/route.ts | 12 +- apps/sim/app/api/logs/cleanup/route.ts | 12 +- .../[id]/invitations/[invitationId]/route.ts | 11 +- .../app/api/organizations/[id]/seats/route.ts | 20 +++- .../me/subscription/[id]/transfer/route.ts | 4 +- apps/sim/app/api/v1/admin/credits/route.ts | 19 +++- .../api/v1/admin/organizations/[id]/route.ts | 10 +- .../api/v1/admin/users/[id]/billing/route.ts | 4 +- apps/sim/app/api/v1/audit-logs/auth.ts | 17 ++- .../credential-sets/credential-sets.tsx | 6 +- .../settings/components/inbox/inbox.tsx | 13 +-- .../components/subscription/subscription.tsx | 24 +++- .../team-management/team-management.tsx | 6 +- .../[workspaceId]/settings/navigation.ts | 3 + .../credential-selector.tsx | 6 +- .../settings-sidebar/settings-sidebar.tsx | 58 +++++----- .../components/access-control.tsx | 31 +++++- apps/sim/ee/sso/components/sso-settings.tsx | 6 +- apps/sim/hooks/queries/organization.ts | 5 +- apps/sim/lib/auth/auth.ts | 3 +- apps/sim/lib/billing/authorization.ts | 4 +- apps/sim/lib/billing/client/index.ts | 1 + apps/sim/lib/billing/client/upgrade.ts | 7 +- apps/sim/lib/billing/client/utils.ts | 24 +++- apps/sim/lib/billing/core/access.ts | 105 ++++++++++++++++++ apps/sim/lib/billing/core/billing.ts | 25 +++-- apps/sim/lib/billing/core/organization.ts | 24 +++- apps/sim/lib/billing/core/plan.ts | 21 +++- apps/sim/lib/billing/core/subscription.ts | 57 ++++++++-- apps/sim/lib/billing/core/usage.ts | 10 +- apps/sim/lib/billing/credits/balance.ts | 8 +- apps/sim/lib/billing/index.ts | 2 +- apps/sim/lib/billing/organization.ts | 4 +- .../lib/billing/organizations/membership.ts | 24 ++-- apps/sim/lib/billing/plan-helpers.ts | 18 +++ .../lib/billing/subscriptions/utils.test.ts | 45 ++++++++ apps/sim/lib/billing/subscriptions/utils.ts | 42 ++++++- apps/sim/lib/billing/threshold-billing.ts | 23 +++- apps/sim/lib/billing/webhooks/subscription.ts | 9 +- .../sim/lib/core/rate-limiter/rate-limiter.ts | 4 +- apps/sim/lib/webhooks/processor.ts | 19 +--- 43 files changed, 597 insertions(+), 242 deletions(-) create mode 100644 apps/sim/lib/billing/core/access.ts create mode 100644 apps/sim/lib/billing/subscriptions/utils.test.ts diff --git a/apps/sim/app/api/billing/portal/route.ts b/apps/sim/app/api/billing/portal/route.ts index 34807f0c6d1..7c42728f729 100644 --- a/apps/sim/app/api/billing/portal/route.ts +++ b/apps/sim/app/api/billing/portal/route.ts @@ -1,11 +1,12 @@ import { db } from '@sim/db' import { subscription as subscriptionTable, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, or } from 'drizzle-orm' +import { and, eq, inArray, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { getBaseUrl } from '@/lib/core/utils/urls' const logger = createLogger('BillingPortal') @@ -45,7 +46,7 @@ export async function POST(request: NextRequest) { and( eq(subscriptionTable.referenceId, organizationId), or( - eq(subscriptionTable.status, 'active'), + inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES), eq(subscriptionTable.cancelAtPeriodEnd, true) ) ) diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts index 1d793243053..3fbae3c1df1 100644 --- a/apps/sim/app/api/billing/route.ts +++ b/apps/sim/app/api/billing/route.ts @@ -1,99 +1,15 @@ import { db } from '@sim/db' -import { member, userStats } from '@sim/db/schema' +import { member } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing' import { getOrganizationBillingData } from '@/lib/billing/core/organization' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getPlanTierCredits } from '@/lib/billing/plan-helpers' -/** - * Gets the effective billing blocked status for a user. - * If user is in an org, also checks if the org owner is blocked. - */ -async function getEffectiveBillingStatus(userId: string): Promise<{ - billingBlocked: boolean - billingBlockedReason: 'payment_failed' | 'dispute' | null - blockedByOrgOwner: boolean -}> { - // Check user's own status - const userStatsRows = await db - .select({ - blocked: userStats.billingBlocked, - blockedReason: userStats.billingBlockedReason, - }) - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) - - const userBlocked = userStatsRows.length > 0 ? !!userStatsRows[0].blocked : false - const userBlockedReason = userStatsRows.length > 0 ? userStatsRows[0].blockedReason : null - - if (userBlocked) { - return { - billingBlocked: true, - billingBlockedReason: userBlockedReason, - blockedByOrgOwner: false, - } - } - - // Check if user is in an org where owner is blocked - const memberships = await db - .select({ organizationId: member.organizationId }) - .from(member) - .where(eq(member.userId, userId)) - - // Fetch all org owners in parallel - const ownerResults = await Promise.all( - memberships.map((m) => - db - .select({ userId: member.userId }) - .from(member) - .where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner'))) - .limit(1) - ) - ) - - // Collect owner IDs that are not the current user - const otherOwnerIds = ownerResults - .filter((owners) => owners.length > 0 && owners[0].userId !== userId) - .map((owners) => owners[0].userId) - - if (otherOwnerIds.length > 0) { - // Fetch all owner stats in parallel - const ownerStatsResults = await Promise.all( - otherOwnerIds.map((ownerId) => - db - .select({ - blocked: userStats.billingBlocked, - blockedReason: userStats.billingBlockedReason, - }) - .from(userStats) - .where(eq(userStats.userId, ownerId)) - .limit(1) - ) - ) - - for (const stats of ownerStatsResults) { - if (stats.length > 0 && stats[0].blocked) { - return { - billingBlocked: true, - billingBlockedReason: stats[0].blockedReason, - blockedByOrgOwner: true, - } - } - } - } - - return { - billingBlocked: false, - billingBlockedReason: null, - blockedByOrgOwner: false, - } -} - const logger = createLogger('UnifiedBillingAPI') /** diff --git a/apps/sim/app/api/billing/switch-plan/route.ts b/apps/sim/app/api/billing/switch-plan/route.ts index bf984a075af..b668335db2a 100644 --- a/apps/sim/app/api/billing/switch-plan/route.ts +++ b/apps/sim/app/api/billing/switch-plan/route.ts @@ -5,12 +5,17 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { writeBillingInterval } from '@/lib/billing/core/subscription' import { getPlanType, isEnterprise, isOrgPlan } from '@/lib/billing/plan-helpers' import { getPlanByName } from '@/lib/billing/plans' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { + hasUsableSubscriptionAccess, + hasUsableSubscriptionStatus, +} from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/feature-flags' const logger = createLogger('SwitchPlan') @@ -60,6 +65,11 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'No active subscription found' }, { status: 404 }) } + const billingStatus = await getEffectiveBillingStatus(userId) + if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) { + return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 }) + } + if (isEnterprise(sub.plan) || isEnterprise(targetPlanName)) { return NextResponse.json( { error: 'Enterprise plan changes must be handled via support' }, @@ -91,7 +101,7 @@ export async function POST(request: NextRequest) { const stripe = requireStripeClient() const stripeSubscription = await stripe.subscriptions.retrieve(sub.stripeSubscriptionId) - if (stripeSubscription.status !== 'active') { + if (!hasUsableSubscriptionStatus(stripeSubscription.status)) { return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 }) } diff --git a/apps/sim/app/api/logs/cleanup/route.ts b/apps/sim/app/api/logs/cleanup/route.ts index 2293199c6b4..25a0acabf55 100644 --- a/apps/sim/app/api/logs/cleanup/route.ts +++ b/apps/sim/app/api/logs/cleanup/route.ts @@ -1,9 +1,11 @@ import { db } from '@sim/db' import { subscription, user, workflowExecutionLogs, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray, lt, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, lt } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' +import { sqlIsPaid } from '@/lib/billing/plan-helpers' +import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { env } from '@/lib/core/config/env' import { snapshotService } from '@/lib/logs/execution/snapshot/service' import { isUsingCloudStorage, StorageService } from '@/lib/uploads' @@ -29,9 +31,13 @@ export async function GET(request: NextRequest) { .from(user) .leftJoin( subscription, - sql`${user.id} = ${subscription.referenceId} AND ${subscription.status} = 'active' AND ${subscription.plan} IN ('pro', 'team', 'enterprise')` + and( + eq(user.id, subscription.referenceId), + inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES), + sqlIsPaid(subscription.plan) + ) ) - .where(sql`${subscription.id} IS NULL`) + .where(isNull(subscription.id)) if (freeUsers.length === 0) { logger.info('No free users found for log cleanup') diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts index d7b1df2a776..044f239d825 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -15,7 +15,7 @@ import { workspaceInvitation, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getEmailSubject, renderInvitationEmail } from '@/components/emails' @@ -23,8 +23,9 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasAccessControlAccess } from '@/lib/billing' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' -import { isOrgPlan } from '@/lib/billing/plan-helpers' +import { isOrgPlan, sqlIsPro } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { getBaseUrl } from '@/lib/core/utils/urls' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -320,7 +321,7 @@ export async function PUT( .where( and( eq(subscriptionTable.referenceId, organizationId), - eq(subscriptionTable.status, 'active') + inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES) ) ) .limit(1) @@ -338,8 +339,8 @@ export async function PUT( .where( and( eq(subscriptionTable.referenceId, userId), - eq(subscriptionTable.status, 'active'), - eq(subscriptionTable.plan, 'pro') + inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES), + sqlIsPro(subscriptionTable.plan) ) ) .limit(1) diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts index 0c3535df3fd..6a2be6238c0 100644 --- a/apps/sim/app/api/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/organizations/[id]/seats/route.ts @@ -1,13 +1,18 @@ import { db } from '@sim/db' import { member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' import { getPlanPricing } from '@/lib/billing/core/billing' import { isTeam } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { + hasUsableSubscriptionStatus, + USABLE_SUBSCRIPTION_STATUSES, +} from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/feature-flags' const logger = createLogger('OrganizationSeatsAPI') @@ -66,7 +71,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const subscriptionRecord = await db .select() .from(subscription) - .where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active'))) + .where( + and( + eq(subscription.referenceId, organizationId), + inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) + ) + ) .limit(1) if (subscriptionRecord.length === 0) { @@ -75,6 +85,10 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const orgSubscription = subscriptionRecord[0] + if (await isOrganizationBillingBlocked(organizationId)) { + return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 }) + } + // Only team plans support seat changes (not enterprise - those are handled manually) if (!isTeam(orgSubscription.plan)) { return NextResponse.json( @@ -127,7 +141,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ orgSubscription.stripeSubscriptionId ) - if (stripeSubscription.status !== 'active') { + if (!hasUsableSubscriptionStatus(stripeSubscription.status)) { return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 }) } diff --git a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts index 16a0023edfb..fad7ddb9a81 100644 --- a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts +++ b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts @@ -5,7 +5,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' -import { hasActiveSubscription } from '@/lib/billing' +import { hasPaidSubscription } from '@/lib/billing' const logger = createLogger('SubscriptionTransferAPI') @@ -90,7 +90,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } // Check if org already has an active subscription (prevent duplicates) - if (await hasActiveSubscription(organizationId)) { + if (await hasPaidSubscription(organizationId)) { return NextResponse.json( { error: 'Organization already has an active subscription' }, { status: 409 } diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts index e2bdfbe079c..93276f8afc3 100644 --- a/apps/sim/app/api/v1/admin/credits/route.ts +++ b/apps/sim/app/api/v1/admin/credits/route.ts @@ -26,12 +26,16 @@ import { db } from '@sim/db' import { organization, subscription, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { nanoid } from 'nanoid' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { addCredits } from '@/lib/billing/credits/balance' import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase' -import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils' +import { isOrgPlan, isPaid } from '@/lib/billing/plan-helpers' +import { + ENTITLED_SUBSCRIPTION_STATUSES, + getEffectiveSeats, +} from '@/lib/billing/subscriptions/utils' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -95,7 +99,7 @@ export const POST = withAdminAuth(async (request) => { const userSubscription = await getHighestPrioritySubscription(resolvedUserId) - if (!userSubscription || !['pro', 'team', 'enterprise'].includes(userSubscription.plan)) { + if (!userSubscription || !isPaid(userSubscription.plan)) { return badRequestResponse( 'User must have an active Pro, Team, or Enterprise subscription to receive credits' ) @@ -106,7 +110,7 @@ export const POST = withAdminAuth(async (request) => { const plan = userSubscription.plan let seats: number | null = null - if (plan === 'team' || plan === 'enterprise') { + if (isOrgPlan(plan)) { entityType = 'organization' entityId = userSubscription.referenceId @@ -123,7 +127,12 @@ export const POST = withAdminAuth(async (request) => { const [subData] = await db .select() .from(subscription) - .where(and(eq(subscription.referenceId, entityId), eq(subscription.status, 'active'))) + .where( + and( + eq(subscription.referenceId, entityId), + inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) + ) + ) .limit(1) seats = getEffectiveSeats(subData) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index 3d0373014ec..5542d8b3131 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -19,7 +19,8 @@ import { db } from '@sim/db' import { member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, count, eq } from 'drizzle-orm' +import { and, count, eq, inArray } from 'drizzle-orm' +import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -58,7 +59,12 @@ export const GET = withAdminAuthParams(async (request, context) => db .select() .from(subscription) - .where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active'))) + .where( + and( + eq(subscription.referenceId, organizationId), + inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) + ) + ) .limit(1), ]) diff --git a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts index 0caf2c655a9..5e863c82ab9 100644 --- a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts @@ -24,6 +24,7 @@ import { createLogger } from '@sim/logger' import { eq, or } from 'drizzle-orm' import { nanoid } from 'nanoid' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { isOrgPlan } from '@/lib/billing/plan-helpers' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -154,8 +155,7 @@ export const PATCH = withAdminAuthParams(async (request, context) = .limit(1) const userSubscription = await getHighestPrioritySubscription(userId) - const isTeamOrEnterpriseMember = - userSubscription && ['team', 'enterprise'].includes(userSubscription.plan) + const isTeamOrEnterpriseMember = userSubscription && isOrgPlan(userSubscription.plan) const [orgMembership] = await db .select({ organizationId: member.organizationId }) diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index 085884488e9..b382e284be2 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -8,8 +8,10 @@ import { db } from '@sim/db' import { member, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { getEffectiveBillingStatus } from '@/lib/billing/core/access' +import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' const logger = createLogger('V1AuditLogsAuth') @@ -57,6 +59,17 @@ export async function validateEnterpriseAuditAccess(userId: string): Promise= 25000 || isEnterprise(plan) + const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data) if (isLoading || (isBillingEnabled && isSubLoading)) { return } - if (isBillingEnabled && !isMaxPlan) { + if (isBillingEnabled && !subscriptionAccess.hasUsableMaxAccess) { return (

- Sim Mailer requires a Max plan + Sim Mailer requires an active Max plan

- Upgrade to Max to receive tasks via email and let Sim work on your behalf. + Upgrade to Max and ensure billing is active to receive tasks via email and let Sim work + on your behalf.

+ )} +
+ + +

{file.name}

+
+ + ) + })} + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/constants.ts new file mode 100644 index 00000000000..3c0f5971d63 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/constants.ts @@ -0,0 +1,93 @@ +import { cn } from '@/lib/core/utils/cn' +import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' +import type { ChatContext } from '@/stores/panel' + +export interface SpeechRecognitionEvent extends Event { + resultIndex: number + results: SpeechRecognitionResultList +} + +export interface SpeechRecognitionErrorEvent extends Event { + error: string +} + +export interface SpeechRecognitionInstance extends EventTarget { + continuous: boolean + interimResults: boolean + lang: string + start(): void + stop(): void + abort(): void + onstart: ((ev: Event) => void) | null + onend: ((ev: Event) => void) | null + onresult: ((ev: SpeechRecognitionEvent) => void) | null + onerror: ((ev: SpeechRecognitionErrorEvent) => void) | null +} + +export interface SpeechRecognitionStatic { + new (): SpeechRecognitionInstance +} + +export type WindowWithSpeech = Window & { + SpeechRecognition?: SpeechRecognitionStatic + webkitSpeechRecognition?: SpeechRecognitionStatic +} + +export interface PlusMenuHandle { + open: () => void +} + +export const TEXTAREA_BASE_CLASSES = cn( + 'm-0 box-border h-auto min-h-[24px] w-full resize-none', + 'overflow-y-auto overflow-x-hidden break-all border-0 bg-transparent', + 'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]', + 'text-transparent caret-[var(--text-primary)] outline-none', + 'placeholder:font-[380] placeholder:text-[var(--text-subtle)]', + 'focus-visible:ring-0 focus-visible:ring-offset-0', + '[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' +) + +export const OVERLAY_CLASSES = cn( + 'pointer-events-none absolute top-0 left-0 m-0 box-border h-auto w-full resize-none', + 'overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all border-0 bg-transparent', + 'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]', + 'text-[var(--text-primary)] outline-none', + '[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' +) + +export const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0 transition-colors' +export const SEND_BUTTON_ACTIVE = + 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]' +export const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]' + +export const MAX_CHAT_TEXTAREA_HEIGHT = 200 +export const SPEECH_RECOGNITION_LANG = 'en-US' + +export function autoResizeTextarea(e: React.FormEvent, maxHeight: number) { + const target = e.target as HTMLTextAreaElement + target.style.height = 'auto' + target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px` +} + +export function mapResourceToContext(resource: MothershipResource): ChatContext { + switch (resource.type) { + case 'workflow': + return { + kind: 'workflow', + workflowId: resource.id, + label: resource.title, + } + case 'knowledgebase': + return { + kind: 'knowledge', + knowledgeId: resource.id, + label: resource.title, + } + case 'table': + return { kind: 'table', tableId: resource.id, label: resource.title } + case 'file': + return { kind: 'file', fileId: resource.id, label: resource.title } + default: + return { kind: 'docs', label: resource.title } + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/drop-overlay.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/drop-overlay.tsx new file mode 100644 index 00000000000..5d0af330c42 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/drop-overlay.tsx @@ -0,0 +1,41 @@ +'use client' + +import React from 'react' +import { + AudioIcon, + CsvIcon, + DocxIcon, + JsonIcon, + MarkdownIcon, + PdfIcon, + TxtIcon, + VideoIcon, + XlsxIcon, +} from '@/components/icons/document-icons' + +const DROP_OVERLAY_ICONS = [ + PdfIcon, + DocxIcon, + XlsxIcon, + CsvIcon, + TxtIcon, + MarkdownIcon, + JsonIcon, + AudioIcon, + VideoIcon, +] as const + +export const DropOverlay = React.memo(function DropOverlay() { + return ( +
+
+ Drop files +
+ {DROP_OVERLAY_ICONS.map((Icon, i) => ( + + ))} +
+
+
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/index.ts new file mode 100644 index 00000000000..a0e71aee024 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/index.ts @@ -0,0 +1,22 @@ +export { AnimatedPlaceholderEffect } from './animated-placeholder-effect' +export { AttachedFilesList } from './attached-files-list' +export type { + PlusMenuHandle, + SpeechRecognitionErrorEvent, + SpeechRecognitionEvent, + SpeechRecognitionInstance, + WindowWithSpeech, +} from './constants' +export { + autoResizeTextarea, + MAX_CHAT_TEXTAREA_HEIGHT, + mapResourceToContext, + OVERLAY_CLASSES, + SPEECH_RECOGNITION_LANG, + TEXTAREA_BASE_CLASSES, +} from './constants' +export { DropOverlay } from './drop-overlay' +export { MicButton } from './mic-button' +export type { AvailableResourceGroup } from './plus-menu-dropdown' +export { PlusMenuDropdown } from './plus-menu-dropdown' +export { SendButton } from './send-button' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/mic-button.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/mic-button.tsx new file mode 100644 index 00000000000..9053013d431 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/mic-button.tsx @@ -0,0 +1,28 @@ +'use client' + +import React from 'react' +import { Mic } from 'lucide-react' +import { cn } from '@/lib/core/utils/cn' + +interface MicButtonProps { + isListening: boolean + onToggle: () => void +} + +export const MicButton = React.memo(function MicButton({ isListening, onToggle }: MicButtonProps) { + return ( + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/plus-menu-dropdown.tsx new file mode 100644 index 00000000000..4e057a04c59 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/plus-menu-dropdown.tsx @@ -0,0 +1,251 @@ +'use client' + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Paperclip } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSearchInput, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/emcn' +import { Plus, Sim } from '@/components/emcn/icons' +import { cn } from '@/lib/core/utils/cn' +import type { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' +import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/_components/constants' +import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' + +export type AvailableResourceGroup = ReturnType[number] + +interface PlusMenuDropdownProps { + availableResources: AvailableResourceGroup[] + onResourceSelect: (resource: MothershipResource) => void + onFileSelect: () => void + onClose: () => void + textareaRef: React.RefObject + pendingCursorRef: React.MutableRefObject +} + +export const PlusMenuDropdown = React.memo( + React.forwardRef(function PlusMenuDropdown( + { availableResources, onResourceSelect, onFileSelect, onClose, textareaRef, pendingCursorRef }, + ref + ) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const [activeIndex, setActiveIndex] = useState(0) + const activeIndexRef = useRef(activeIndex) + + useEffect(() => { + activeIndexRef.current = activeIndex + }, [activeIndex]) + + React.useImperativeHandle( + ref, + () => ({ + open: () => { + setOpen(true) + setSearch('') + setActiveIndex(0) + }, + }), + [] + ) + + const filteredItems = useMemo(() => { + const q = search.toLowerCase().trim() + if (!q) return null + return availableResources.flatMap(({ type, items }) => + items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item })) + ) + }, [search, availableResources]) + + const handleSelect = useCallback( + (resource: MothershipResource) => { + onResourceSelect(resource) + setOpen(false) + setSearch('') + setActiveIndex(0) + }, + [onResourceSelect] + ) + + const filteredItemsRef = useRef(filteredItems) + filteredItemsRef.current = filteredItems + + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const items = filteredItemsRef.current + if (!items) return + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIndex((prev) => Math.min(prev + 1, items.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIndex((prev) => Math.max(prev - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + const idx = activeIndexRef.current + if (items.length > 0 && items[idx]) { + const { type, item } = items[idx] + handleSelect({ type, id: item.id, title: item.name }) + } + } + }, + [handleSelect] + ) + + const handleOpenChange = useCallback( + (isOpen: boolean) => { + setOpen(isOpen) + if (!isOpen) { + setSearch('') + setActiveIndex(0) + onClose() + } + }, + [onClose] + ) + + const handleCloseAutoFocus = useCallback( + (e: Event) => { + e.preventDefault() + const textarea = textareaRef.current + if (!textarea) return + if (pendingCursorRef.current !== null) { + textarea.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current) + pendingCursorRef.current = null + } + textarea.focus() + }, + [textareaRef, pendingCursorRef] + ) + + return ( + + + + + + { + setSearch(e.target.value) + setActiveIndex(0) + }} + onKeyDown={handleSearchKeyDown} + /> +
+ {filteredItems ? ( + filteredItems.length > 0 ? ( + filteredItems.map(({ type, item }, index) => { + const config = getResourceConfig(type) + return ( + setActiveIndex(index)} + onClick={() => { + handleSelect({ + type, + id: item.id, + title: item.name, + }) + }} + > + {config.renderDropdownItem({ item })} + + {config.label} + + + ) + }) + ) : ( +
+ No results +
+ ) + ) : ( + <> + { + setOpen(false) + onFileSelect() + }} + > + + Attachments + + + + + Workspace + + + {availableResources.map(({ type, items }) => { + if (items.length === 0) return null + const config = getResourceConfig(type) + const Icon = config.icon + return ( + + + {type === 'workflow' ? ( +
+ ) : ( + + )} + {config.label} + + + {items.map((item) => ( + { + handleSelect({ + type, + id: item.id, + title: item.name, + }) + }} + > + {config.renderDropdownItem({ item })} + + ))} + + + ) + })} + + + + )} +
+ + + ) + }) +) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/send-button.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/send-button.tsx new file mode 100644 index 00000000000..74edc7d60d3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/send-button.tsx @@ -0,0 +1,52 @@ +'use client' + +import React from 'react' +import { ArrowUp } from 'lucide-react' +import { Button } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import { + SEND_BUTTON_ACTIVE, + SEND_BUTTON_BASE, + SEND_BUTTON_DISABLED, +} from '@/app/workspace/[workspaceId]/home/components/user-input/_components/constants' + +interface SendButtonProps { + isSending: boolean + canSubmit: boolean + onSubmit: () => void + onStopGeneration: () => void +} + +export const SendButton = React.memo(function SendButton({ + isSending, + canSubmit, + onSubmit, + onStopGeneration, +}: SendButtonProps) { + if (isSending) { + return ( + + ) + } + return ( + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index a69cc08477d..3a549351c53 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -1,69 +1,35 @@ 'use client' -interface SpeechRecognitionEvent extends Event { - resultIndex: number - results: SpeechRecognitionResultList -} - -interface SpeechRecognitionErrorEvent extends Event { - error: string -} - -interface SpeechRecognitionInstance extends EventTarget { - continuous: boolean - interimResults: boolean - lang: string - start(): void - stop(): void - abort(): void - onstart: ((ev: Event) => void) | null - onend: ((ev: Event) => void) | null - onresult: ((ev: SpeechRecognitionEvent) => void) | null - onerror: ((ev: SpeechRecognitionErrorEvent) => void) | null -} - -interface SpeechRecognitionStatic { - new (): SpeechRecognitionInstance -} - -type WindowWithSpeech = Window & { - SpeechRecognition?: SpeechRecognitionStatic - webkitSpeechRecognition?: SpeechRecognitionStatic -} - +import type React from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { ArrowUp, Loader2, Mic, Paperclip, X } from 'lucide-react' import { useParams } from 'next/navigation' -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSearchInput, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, - Tooltip, -} from '@/components/emcn' -import { Database, Plus, Sim, Table as TableIcon } from '@/components/emcn/icons' -import { - AudioIcon, - CsvIcon, - DocxIcon, - getDocumentIcon, - JsonIcon, - MarkdownIcon, - PdfIcon, - TxtIcon, - VideoIcon, - XlsxIcon, -} from '@/components/icons/document-icons' +import { Database, Table as TableIcon } from '@/components/emcn/icons' +import { getDocumentIcon } from '@/components/icons/document-icons' import { useSession } from '@/lib/auth/auth-client' import { cn } from '@/lib/core/utils/cn' import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' -import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import type { + PlusMenuHandle, + SpeechRecognitionErrorEvent, + SpeechRecognitionEvent, + SpeechRecognitionInstance, + WindowWithSpeech, +} from '@/app/workspace/[workspaceId]/home/components/user-input/_components' +import { + AnimatedPlaceholderEffect, + AttachedFilesList, + autoResizeTextarea, + DropOverlay, + MAX_CHAT_TEXTAREA_HEIGHT, + MicButton, + mapResourceToContext, + OVERLAY_CLASSES, + PlusMenuDropdown, + SendButton, + SPEECH_RECOGNITION_LANG, + TEXTAREA_BASE_CLASSES, +} from '@/app/workspace/[workspaceId]/home/components/user-input/_components' import type { FileAttachmentForApi, MothershipResource, @@ -74,80 +40,13 @@ import { useMentionMenu, useMentionTokens, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks' +import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' import { computeMentionHighlightRanges, extractContextTokens, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' import type { ChatContext } from '@/stores/panel' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useAnimatedPlaceholder } from '../../hooks' - -const TEXTAREA_BASE_CLASSES = cn( - 'm-0 box-border h-auto min-h-[24px] w-full resize-none', - 'overflow-y-auto overflow-x-hidden break-all border-0 bg-transparent', - 'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]', - 'text-transparent caret-[var(--text-primary)] outline-none', - 'placeholder:font-[380] placeholder:text-[var(--text-subtle)]', - 'focus-visible:ring-0 focus-visible:ring-offset-0', - '[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' -) - -const OVERLAY_CLASSES = cn( - 'pointer-events-none absolute top-0 left-0 m-0 box-border h-auto w-full resize-none', - 'overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all border-0 bg-transparent', - 'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]', - 'text-[var(--text-primary)] outline-none', - '[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' -) - -const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0 transition-colors' -const SEND_BUTTON_ACTIVE = - 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]' -const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]' - -const MAX_CHAT_TEXTAREA_HEIGHT = 200 -const SPEECH_RECOGNITION_LANG = 'en-US' - -const DROP_OVERLAY_ICONS = [ - PdfIcon, - DocxIcon, - XlsxIcon, - CsvIcon, - TxtIcon, - MarkdownIcon, - JsonIcon, - AudioIcon, - VideoIcon, -] as const - -function autoResizeTextarea(e: React.FormEvent, maxHeight: number) { - const target = e.target as HTMLTextAreaElement - target.style.height = 'auto' - target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px` -} - -function mapResourceToContext(resource: MothershipResource): ChatContext { - switch (resource.type) { - case 'workflow': - return { - kind: 'workflow', - workflowId: resource.id, - label: resource.title, - } - case 'knowledgebase': - return { - kind: 'knowledge', - knowledgeId: resource.id, - label: resource.title, - } - case 'table': - return { kind: 'table', tableId: resource.id, label: resource.title } - case 'file': - return { kind: 'file', fileId: resource.id, label: resource.title } - default: - return { kind: 'docs', label: resource.title } - } -} export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' @@ -181,10 +80,8 @@ export function UserInput({ const { workspaceId } = useParams<{ workspaceId: string }>() const { data: session } = useSession() const [value, setValue] = useState(defaultValue) - const [plusMenuOpen, setPlusMenuOpen] = useState(false) - const [plusMenuSearch, setPlusMenuSearch] = useState('') - const [plusMenuActiveIndex, setPlusMenuActiveIndex] = useState(0) const overlayRef = useRef(null) + const plusMenuRef = useRef(null) const [prevDefaultValue, setPrevDefaultValue] = useState(defaultValue) if (defaultValue && defaultValue !== prevDefaultValue) { @@ -206,9 +103,6 @@ export function UserInput({ if (editValue) onEditValueConsumed?.() }, [editValue, onEditValueConsumed]) - const animatedPlaceholder = useAnimatedPlaceholder(isInitialView) - const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim' - const files = useFileAttachments({ userId: userId || session?.user?.id, workspaceId, @@ -219,12 +113,14 @@ export function UserInput({ const contextManagement = useContextManagement({ message: value }) + const { addContext } = contextManagement + const handleContextAdd = useCallback( (context: ChatContext) => { - contextManagement.addContext(context) + addContext(context) onContextAdd?.(context) }, - [contextManagement, onContextAdd] + [addContext, onContextAdd] ) const existingResourceKeys = useMemo(() => { @@ -240,14 +136,6 @@ export function UserInput({ const availableResources = useAvailableResources(workspaceId, existingResourceKeys) - const filteredPlusMenuItems = useMemo(() => { - const q = plusMenuSearch.toLowerCase().trim() - if (!q) return null - return availableResources.flatMap(({ type, items }) => - items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item })) - ) - }, [plusMenuSearch, availableResources]) - const mentionMenu = useMentionMenu({ message: value, selectedContexts: contextManagement.selectedContexts, @@ -270,6 +158,11 @@ export function UserInput({ const prefixRef = useRef('') const valueRef = useRef(value) + const filesRef = useRef(files) + filesRef.current = files + const contextRef = useRef(contextManagement) + contextRef.current = contextManagement + useEffect(() => { return () => { recognitionRef.current?.abort() @@ -300,13 +193,14 @@ export function UserInput({ (resource: MothershipResource) => { const textarea = textareaRef.current if (textarea) { - const insertAt = atInsertPosRef.current ?? textarea.selectionStart ?? value.length + const currentValue = valueRef.current + const insertAt = atInsertPosRef.current ?? textarea.selectionStart ?? currentValue.length atInsertPosRef.current = null - const needsSpaceBefore = insertAt > 0 && !/\s/.test(value.charAt(insertAt - 1)) + const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1)) const insertText = `${needsSpaceBefore ? ' ' : ''}@${resource.title} ` - const before = value.slice(0, insertAt) - const after = value.slice(insertAt) + const before = currentValue.slice(0, insertAt) + const after = currentValue.slice(insertAt) const newPos = before.length + insertText.length pendingCursorRef.current = newPos setValue(`${before}${insertText}${after}`) @@ -314,47 +208,35 @@ export function UserInput({ const context = mapResourceToContext(resource) handleContextAdd(context) - setPlusMenuOpen(false) }, - [textareaRef, value, handleContextAdd] + [textareaRef, handleContextAdd] ) - const handlePlusMenuSearchKeyDown = useCallback( - (e: React.KeyboardEvent) => { - const items = filteredPlusMenuItems - if (!items) return - if (e.key === 'ArrowDown') { - e.preventDefault() - setPlusMenuActiveIndex((prev) => Math.min(prev + 1, items.length - 1)) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - setPlusMenuActiveIndex((prev) => Math.max(prev - 1, 0)) - } else if (e.key === 'Enter') { - e.preventDefault() - if (items.length > 0 && items[plusMenuActiveIndex]) { - const { type, item } = items[plusMenuActiveIndex] - handleResourceSelect({ type, id: item.id, title: item.name }) - setPlusMenuOpen(false) - setPlusMenuSearch('') - setPlusMenuActiveIndex(0) - } - } - }, - [filteredPlusMenuItems, plusMenuActiveIndex, handleResourceSelect] - ) + const handlePlusMenuClose = useCallback(() => { + atInsertPosRef.current = null + }, []) - const handleContainerDragOver = useCallback( - (e: React.DragEvent) => { - if (e.dataTransfer.types.includes('application/x-sim-resource')) { - e.preventDefault() - e.stopPropagation() - e.dataTransfer.dropEffect = 'copy' - return - } - files.handleDragOver(e) - }, - [files] - ) + const handleFileSelectStable = useCallback(() => { + filesRef.current.handleFileSelect() + }, []) + + const handleFileClick = useCallback((file: AttachedFile) => { + filesRef.current.handleFileClick(file) + }, []) + + const handleRemoveFile = useCallback((id: string) => { + filesRef.current.removeFile(id) + }, []) + + const handleContainerDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes('application/x-sim-resource')) { + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'copy' + return + } + filesRef.current.handleDragOver(e) + }, []) const handleContainerDrop = useCallback( (e: React.DragEvent) => { @@ -370,11 +252,23 @@ export function UserInput({ } return } - files.handleDrop(e) + filesRef.current.handleDrop(e) }, - [handleResourceSelect, files] + [handleResourceSelect] ) + const handleDragEnter = useCallback((e: React.DragEvent) => { + filesRef.current.handleDragEnter(e) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + filesRef.current.handleDragLeave(e) + }, []) + + const handleFileChange = useCallback((e: React.ChangeEvent) => { + filesRef.current.handleFileChange(e) + }, []) + useEffect(() => { if (wasSendingRef.current && !isSending) { textareaRef.current?.focus() @@ -468,14 +362,18 @@ export function UserInput({ return } - prefixRef.current = value + prefixRef.current = valueRef.current if (startRecognition()) { setIsListening(true) } - }, [isListening, value, startRecognition]) + }, [isListening, startRecognition]) const handleSubmit = useCallback(() => { - const fileAttachmentsForApi: FileAttachmentForApi[] = files.attachedFiles + const currentFiles = filesRef.current + const currentContext = contextRef.current + const currentValue = valueRef.current + + const fileAttachmentsForApi: FileAttachmentForApi[] = currentFiles.attachedFiles .filter((f) => !f.uploading && f.key) .map((f) => ({ id: f.id, @@ -486,19 +384,19 @@ export function UserInput({ })) onSubmit( - value, + currentValue, fileAttachmentsForApi.length > 0 ? fileAttachmentsForApi : undefined, - contextManagement.selectedContexts.length > 0 ? contextManagement.selectedContexts : undefined + currentContext.selectedContexts.length > 0 ? currentContext.selectedContexts : undefined ) setValue('') restartRecognition('') - files.clearAttachedFiles() - contextManagement.clearContexts() + currentFiles.clearAttachedFiles() + currentContext.clearContexts() if (textareaRef.current) { textareaRef.current.style.height = 'auto' } - }, [onSubmit, files, value, contextManagement, textareaRef, restartRecognition]) + }, [onSubmit, restartRecognition, textareaRef]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -588,9 +486,7 @@ export function UserInput({ const adjusted = `${before}${after}` setValue(adjusted) atInsertPosRef.current = caret - 1 - setPlusMenuOpen(true) - setPlusMenuSearch('') - setPlusMenuActiveIndex(0) + plusMenuRef.current?.open() restartRecognition(adjusted) return } @@ -619,7 +515,6 @@ export function UserInput({ const maxHeight = isInitialView ? window.innerHeight * 0.3 : MAX_CHAT_TEXTAREA_HEIGHT autoResizeTextarea(e, maxHeight) - // Sync overlay scroll if (overlayRef.current) { overlayRef.current.scrollTop = (e.target as HTMLTextAreaElement).scrollTop } @@ -627,7 +522,13 @@ export function UserInput({ [isInitialView] ) - const renderOverlayContent = useCallback(() => { + const handleScroll = useCallback((e: React.UIEvent) => { + if (overlayRef.current) { + overlayRef.current.scrollTop = e.currentTarget.scrollTop + } + }, []) + + const overlayContent = useMemo(() => { const contexts = contextManagement.selectedContexts if (!value) { @@ -732,77 +633,26 @@ export function UserInput({ 'relative z-10 mx-auto w-full max-w-[42rem] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-[10px] py-[8px] dark:bg-[var(--surface-4)]', isInitialView && 'shadow-sm' )} - onDragEnter={files.handleDragEnter} - onDragLeave={files.handleDragLeave} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} onDragOver={handleContainerDragOver} onDrop={handleContainerDrop} > - {/* Attached files */} - {files.attachedFiles.length > 0 && ( -
- {files.attachedFiles.map((file) => { - const isImage = file.type.startsWith('image/') - return ( - - -
files.handleFileClick(file)} - > - {isImage && file.previewUrl ? ( - {file.name} - ) : ( -
- {(() => { - const Icon = getDocumentIcon(file.type, file.name) - return - })()} - - {file.name.split('.').pop()} - -
- )} - {file.uploading && ( -
- -
- )} - {!file.uploading && ( - - )} -
-
- -

{file.name}

-
-
- ) - })} -
- )} + + + - {/* Textarea with overlay for highlighting */}
- {/* Highlight overlay */}