Compare commits

..

2 Commits

Author SHA1 Message Date
Cursor Agent
01f02f1169 fix firecrawl scrape creditsUsed mapping 2026-03-14 17:44:59 +00:00
Theodore Li
fecc435c10 fix(firecrawl) fix firecrawl scrape credit usage calculation 2026-03-14 10:37:17 -07:00
102 changed files with 2604 additions and 29814 deletions

View File

@@ -33,26 +33,11 @@
opacity: 0;
}
html[data-sidebar-collapsed] .sidebar-container span,
html[data-sidebar-collapsed] .sidebar-container .text-small {
opacity: 0;
}
.sidebar-container .sidebar-collapse-hide {
transition: opacity 60ms ease;
}
.sidebar-container[data-collapsed] .sidebar-collapse-hide,
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide {
opacity: 0;
}
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove {
display: none;
}
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
width: 0;
.sidebar-container[data-collapsed] .sidebar-collapse-hide {
opacity: 0;
}

View File

@@ -0,0 +1,187 @@
/**
* POST /api/attribution
*
* Automatic UTM-based referral attribution.
*
* Reads the `sim_utm` cookie (set by proxy on auth pages), matches a campaign
* by UTM specificity, and atomically inserts an attribution record + applies
* bonus credits.
*
* Idempotent — the unique constraint on `userId` prevents double-attribution.
*/
import { db } from '@sim/db'
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
const logger = createLogger('AttributionAPI')
const COOKIE_NAME = 'sim_utm'
const UtmCookieSchema = z.object({
utm_source: z.string().optional(),
utm_medium: z.string().optional(),
utm_campaign: z.string().optional(),
utm_content: z.string().optional(),
referrer_url: z.string().optional(),
landing_page: z.string().optional(),
created_at: z.string().optional(),
})
/**
* Finds the most specific active campaign matching the given UTM params.
* Null fields on a campaign act as wildcards. Ties broken by newest campaign.
*/
async function findMatchingCampaign(utmData: z.infer<typeof UtmCookieSchema>) {
const campaigns = await db
.select()
.from(referralCampaigns)
.where(eq(referralCampaigns.isActive, true))
let bestMatch: (typeof campaigns)[number] | null = null
let bestScore = -1
for (const campaign of campaigns) {
let score = 0
let mismatch = false
const fields = [
{ campaignVal: campaign.utmSource, utmVal: utmData.utm_source },
{ campaignVal: campaign.utmMedium, utmVal: utmData.utm_medium },
{ campaignVal: campaign.utmCampaign, utmVal: utmData.utm_campaign },
{ campaignVal: campaign.utmContent, utmVal: utmData.utm_content },
] as const
for (const { campaignVal, utmVal } of fields) {
if (campaignVal === null) continue
if (campaignVal === utmVal) {
score++
} else {
mismatch = true
break
}
}
if (!mismatch && score > 0) {
if (
score > bestScore ||
(score === bestScore &&
bestMatch &&
campaign.createdAt.getTime() > bestMatch.createdAt.getTime())
) {
bestScore = score
bestMatch = campaign
}
}
}
return bestMatch
}
export async function POST() {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const cookieStore = await cookies()
const utmCookie = cookieStore.get(COOKIE_NAME)
if (!utmCookie?.value) {
return NextResponse.json({ attributed: false, reason: 'no_utm_cookie' })
}
let utmData: z.infer<typeof UtmCookieSchema>
try {
let decoded: string
try {
decoded = decodeURIComponent(utmCookie.value)
} catch {
decoded = utmCookie.value
}
utmData = UtmCookieSchema.parse(JSON.parse(decoded))
} catch {
logger.warn('Failed to parse UTM cookie', { userId: session.user.id })
cookieStore.delete(COOKIE_NAME)
return NextResponse.json({ attributed: false, reason: 'invalid_cookie' })
}
const matchedCampaign = await findMatchingCampaign(utmData)
if (!matchedCampaign) {
cookieStore.delete(COOKIE_NAME)
return NextResponse.json({ attributed: false, reason: 'no_matching_campaign' })
}
const bonusAmount = Number(matchedCampaign.bonusCreditAmount)
let attributed = false
await db.transaction(async (tx) => {
const [existingStats] = await tx
.select({ id: userStats.id })
.from(userStats)
.where(eq(userStats.userId, session.user.id))
.limit(1)
if (!existingStats) {
await tx.insert(userStats).values({
id: nanoid(),
userId: session.user.id,
})
}
const result = await tx
.insert(referralAttribution)
.values({
id: nanoid(),
userId: session.user.id,
campaignId: matchedCampaign.id,
utmSource: utmData.utm_source || null,
utmMedium: utmData.utm_medium || null,
utmCampaign: utmData.utm_campaign || null,
utmContent: utmData.utm_content || null,
referrerUrl: utmData.referrer_url || null,
landingPage: utmData.landing_page || null,
bonusCreditAmount: bonusAmount.toString(),
})
.onConflictDoNothing({ target: referralAttribution.userId })
.returning({ id: referralAttribution.id })
if (result.length > 0) {
await applyBonusCredits(session.user.id, bonusAmount, tx)
attributed = true
}
})
if (attributed) {
logger.info('Referral attribution created and bonus credits applied', {
userId: session.user.id,
campaignId: matchedCampaign.id,
campaignName: matchedCampaign.name,
utmSource: utmData.utm_source,
utmCampaign: utmData.utm_campaign,
utmContent: utmData.utm_content,
bonusAmount,
})
} else {
logger.info('User already attributed, skipping', { userId: session.user.id })
}
cookieStore.delete(COOKIE_NAME)
return NextResponse.json({
attributed,
bonusAmount: attributed ? bonusAmount : undefined,
reason: attributed ? undefined : 'already_attributed',
})
} catch (error) {
logger.error('Attribution error', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -120,8 +120,8 @@ export async function verifyFileAccess(
return true
}
// 1. Workspace / mothership files: Check database first (most reliable for both local and cloud)
if (inferredContext === 'workspace' || inferredContext === 'mothership') {
// 1. Workspace files: Check database first (most reliable for both local and cloud)
if (inferredContext === 'workspace') {
return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal)
}

View File

@@ -4,7 +4,6 @@ import { sanitizeFileName } from '@/executor/constants'
import '@/lib/uploads/core/setup.server'
import { getSession } from '@/lib/auth'
import type { StorageContext } from '@/lib/uploads/config'
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils'
import {
SUPPORTED_AUDIO_EXTENSIONS,
@@ -47,10 +46,9 @@ export async function POST(request: NextRequest) {
const formData = await request.formData()
const rawFiles = formData.getAll('file')
const files = rawFiles.filter((f): f is File => f instanceof File)
const files = formData.getAll('file') as File[]
if (files.length === 0) {
if (!files || files.length === 0) {
throw new InvalidRequestError('No files provided')
}
@@ -75,7 +73,7 @@ export async function POST(request: NextRequest) {
const uploadResults = []
for (const file of files) {
const originalName = file.name || 'untitled'
const originalName = file.name
if (!validateFileExtension(originalName)) {
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'
@@ -233,53 +231,6 @@ export async function POST(request: NextRequest) {
}
}
// Handle mothership context (chat-scoped uploads to workspace S3)
if (context === 'mothership') {
if (!workspaceId) {
throw new InvalidRequestError('Mothership context requires workspaceId parameter')
}
logger.info(`Uploading mothership file: ${originalName}`)
const storageKey = generateWorkspaceFileKey(workspaceId, originalName)
const metadata: Record<string, string> = {
originalName: originalName,
uploadedAt: new Date().toISOString(),
purpose: 'mothership',
userId: session.user.id,
workspaceId,
}
const fileInfo = await storageService.uploadFile({
file: buffer,
fileName: storageKey,
contentType: file.type || 'application/octet-stream',
context: 'mothership',
preserveKey: true,
customKey: storageKey,
metadata,
})
const finalPath = usingCloudStorage ? `${fileInfo.path}?context=mothership` : fileInfo.path
uploadResults.push({
fileName: originalName,
presignedUrl: '',
fileInfo: {
path: finalPath,
key: fileInfo.key,
name: originalName,
size: buffer.length,
type: file.type || 'application/octet-stream',
},
directUploadSupported: false,
})
logger.info(`Successfully uploaded mothership file: ${fileInfo.key}`)
continue
}
// Handle copilot, chat, profile-pictures contexts
if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') {
if (context === 'copilot') {

View File

@@ -31,8 +31,6 @@ const FileAttachmentSchema = z.object({
const ResourceAttachmentSchema = z.object({
type: z.enum(['workflow', 'table', 'file', 'knowledgebase']),
id: z.string().min(1),
title: z.string().optional(),
active: z.boolean().optional(),
})
const MothershipMessageSchema = z.object({
@@ -126,19 +124,9 @@ export async function POST(req: NextRequest) {
if (Array.isArray(resourceAttachments) && resourceAttachments.length > 0) {
const results = await Promise.allSettled(
resourceAttachments.map(async (r) => {
const ctx = await resolveActiveResourceContext(
r.type,
r.id,
workspaceId,
authenticatedUserId
)
if (!ctx) return null
return {
...ctx,
tag: r.active ? '@active_tab' : '@open_tab',
}
})
resourceAttachments.map((r) =>
resolveActiveResourceContext(r.type, r.id, workspaceId, authenticatedUserId)
)
)
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {

View File

@@ -0,0 +1,171 @@
/**
* POST /api/referral-code/redeem
*
* Redeem a referral/promo code to receive bonus credits.
*
* Body:
* - code: string — The referral code to redeem
*
* Response: { redeemed: boolean, bonusAmount?: number, error?: string }
*
* Constraints:
* - Enterprise users cannot redeem codes
* - One redemption per user, ever (unique constraint on userId)
* - One redemption per organization for team users (partial unique on organizationId)
*/
import { db } from '@sim/db'
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers'
const logger = createLogger('ReferralCodeRedemption')
const RedeemCodeSchema = z.object({
code: z.string().min(1, 'Code is required'),
})
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { code } = RedeemCodeSchema.parse(body)
const subscription = await getHighestPrioritySubscription(session.user.id)
if (isEnterprise(subscription?.plan)) {
return NextResponse.json({
redeemed: false,
error: 'Enterprise accounts cannot redeem referral codes',
})
}
const isTeamSub = isTeam(subscription?.plan)
const orgId = isTeamSub ? subscription!.referenceId : null
const normalizedCode = code.trim().toUpperCase()
const [campaign] = await db
.select()
.from(referralCampaigns)
.where(and(eq(referralCampaigns.code, normalizedCode), eq(referralCampaigns.isActive, true)))
.limit(1)
if (!campaign) {
logger.info('Invalid code redemption attempt', {
userId: session.user.id,
code: normalizedCode,
})
return NextResponse.json({ error: 'Invalid or expired code' }, { status: 404 })
}
const [existingUserAttribution] = await db
.select({ id: referralAttribution.id })
.from(referralAttribution)
.where(eq(referralAttribution.userId, session.user.id))
.limit(1)
if (existingUserAttribution) {
return NextResponse.json({
redeemed: false,
error: 'You have already redeemed a code',
})
}
if (orgId) {
const [existingOrgAttribution] = await db
.select({ id: referralAttribution.id })
.from(referralAttribution)
.where(eq(referralAttribution.organizationId, orgId))
.limit(1)
if (existingOrgAttribution) {
return NextResponse.json({
redeemed: false,
error: 'A code has already been redeemed for your organization',
})
}
}
const bonusAmount = Number(campaign.bonusCreditAmount)
let redeemed = false
await db.transaction(async (tx) => {
const [existingStats] = await tx
.select({ id: userStats.id })
.from(userStats)
.where(eq(userStats.userId, session.user.id))
.limit(1)
if (!existingStats) {
await tx.insert(userStats).values({
id: nanoid(),
userId: session.user.id,
})
}
const result = await tx
.insert(referralAttribution)
.values({
id: nanoid(),
userId: session.user.id,
organizationId: orgId,
campaignId: campaign.id,
utmSource: null,
utmMedium: null,
utmCampaign: null,
utmContent: null,
referrerUrl: null,
landingPage: null,
bonusCreditAmount: bonusAmount.toString(),
})
.onConflictDoNothing()
.returning({ id: referralAttribution.id })
if (result.length > 0) {
await applyBonusCredits(session.user.id, bonusAmount, tx)
redeemed = true
}
})
if (redeemed) {
logger.info('Referral code redeemed', {
userId: session.user.id,
organizationId: orgId,
code: normalizedCode,
campaignId: campaign.id,
campaignName: campaign.name,
bonusAmount,
})
}
if (!redeemed) {
return NextResponse.json({
redeemed: false,
error: 'You have already redeemed a code',
})
}
return NextResponse.json({
redeemed: true,
bonusAmount,
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
}
logger.error('Referral code redemption error', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -103,6 +103,7 @@ export type {
AdminOrganization,
AdminOrganizationBillingSummary,
AdminOrganizationDetail,
AdminReferralCampaign,
AdminSeatAnalytics,
AdminSingleResponse,
AdminSubscription,
@@ -117,6 +118,7 @@ export type {
AdminWorkspaceMember,
DbMember,
DbOrganization,
DbReferralCampaign,
DbSubscription,
DbUser,
DbUserStats,
@@ -145,6 +147,7 @@ export {
parseWorkflowVariables,
toAdminFolder,
toAdminOrganization,
toAdminReferralCampaign,
toAdminSubscription,
toAdminUser,
toAdminWorkflow,

View File

@@ -0,0 +1,142 @@
/**
* GET /api/v1/admin/referral-campaigns/:id
*
* Get a single referral campaign by ID.
*
* PATCH /api/v1/admin/referral-campaigns/:id
*
* Update campaign fields. All fields are optional.
*
* Body:
* - name: string (non-empty) - Campaign name
* - bonusCreditAmount: number (> 0) - Bonus credits in dollars
* - isActive: boolean - Enable/disable the campaign
* - code: string | null (min 6 chars, auto-uppercased, null to remove) - Redeemable code
* - utmSource: string | null - UTM source match (null = wildcard)
* - utmMedium: string | null - UTM medium match (null = wildcard)
* - utmCampaign: string | null - UTM campaign match (null = wildcard)
* - utmContent: string | null - UTM content match (null = wildcard)
*/
import { db } from '@sim/db'
import { referralCampaigns } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { toAdminReferralCampaign } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminReferralCampaignDetailAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
try {
const { id: campaignId } = await context.params
const [campaign] = await db
.select()
.from(referralCampaigns)
.where(eq(referralCampaigns.id, campaignId))
.limit(1)
if (!campaign) {
return notFoundResponse('Campaign')
}
logger.info(`Admin API: Retrieved referral campaign ${campaignId}`)
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
} catch (error) {
logger.error('Admin API: Failed to get referral campaign', { error })
return internalErrorResponse('Failed to get referral campaign')
}
})
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
try {
const { id: campaignId } = await context.params
const body = await request.json()
const [existing] = await db
.select()
.from(referralCampaigns)
.where(eq(referralCampaigns.id, campaignId))
.limit(1)
if (!existing) {
return notFoundResponse('Campaign')
}
const updateData: Record<string, unknown> = { updatedAt: new Date() }
if (body.name !== undefined) {
if (typeof body.name !== 'string' || body.name.trim().length === 0) {
return badRequestResponse('name must be a non-empty string')
}
updateData.name = body.name.trim()
}
if (body.bonusCreditAmount !== undefined) {
if (
typeof body.bonusCreditAmount !== 'number' ||
!Number.isFinite(body.bonusCreditAmount) ||
body.bonusCreditAmount <= 0
) {
return badRequestResponse('bonusCreditAmount must be a positive number')
}
updateData.bonusCreditAmount = body.bonusCreditAmount.toString()
}
if (body.isActive !== undefined) {
if (typeof body.isActive !== 'boolean') {
return badRequestResponse('isActive must be a boolean')
}
updateData.isActive = body.isActive
}
if (body.code !== undefined) {
if (body.code !== null) {
if (typeof body.code !== 'string') {
return badRequestResponse('code must be a string or null')
}
if (body.code.trim().length < 6) {
return badRequestResponse('code must be at least 6 characters')
}
}
updateData.code = body.code ? body.code.trim().toUpperCase() : null
}
for (const field of ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent'] as const) {
if (body[field] !== undefined) {
if (body[field] !== null && typeof body[field] !== 'string') {
return badRequestResponse(`${field} must be a string or null`)
}
updateData[field] = body[field] || null
}
}
const [updated] = await db
.update(referralCampaigns)
.set(updateData)
.where(eq(referralCampaigns.id, campaignId))
.returning()
logger.info(`Admin API: Updated referral campaign ${campaignId}`, {
fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
})
return singleResponse(toAdminReferralCampaign(updated, getBaseUrl()))
} catch (error) {
logger.error('Admin API: Failed to update referral campaign', { error })
return internalErrorResponse('Failed to update referral campaign')
}
})

View File

@@ -1,160 +1,104 @@
/**
* GET /api/v1/admin/referral-campaigns
*
* List Stripe promotion codes with cursor-based pagination.
* List referral campaigns with optional filtering and pagination.
*
* Query Parameters:
* - limit: number (default: 50, max: 100)
* - starting_after: string (cursor — Stripe promotion code ID)
* - active: 'true' | 'false' (optional filter)
* - active: string (optional) - Filter by active status ('true' or 'false')
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* POST /api/v1/admin/referral-campaigns
*
* Create a Stripe coupon and an associated promotion code.
* Create a new referral campaign.
*
* Body:
* - name: string (required) — Display name for the coupon
* - percentOff: number (required, 1100) — Percentage discount
* - code: string | null (optional, min 6 chars, auto-uppercased) — Desired code
* - duration: 'once' | 'repeating' | 'forever' (default: 'once')
* - durationInMonths: number (required when duration is 'repeating')
* - maxRedemptions: number (optional) — Total redemption cap
* - expiresAt: ISO 8601 string (optional) — Promotion code expiry
* - name: string (required) - Campaign name
* - bonusCreditAmount: number (required, > 0) - Bonus credits in dollars
* - code: string | null (optional, min 6 chars, auto-uppercased) - Redeemable code
* - utmSource: string | null (optional) - UTM source match (null = wildcard)
* - utmMedium: string | null (optional) - UTM medium match (null = wildcard)
* - utmCampaign: string | null (optional) - UTM campaign match (null = wildcard)
* - utmContent: string | null (optional) - UTM content match (null = wildcard)
*/
import { db } from '@sim/db'
import { referralCampaigns } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import type Stripe from 'stripe'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { count, eq, type SQL } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminReferralCampaign,
createPaginationMeta,
parsePaginationParams,
toAdminReferralCampaign,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminPromoCodes')
const VALID_DURATIONS = ['once', 'repeating', 'forever'] as const
type Duration = (typeof VALID_DURATIONS)[number]
interface PromoCodeResponse {
id: string
code: string
couponId: string
name: string
percentOff: number
duration: string
durationInMonths: number | null
maxRedemptions: number | null
expiresAt: string | null
active: boolean
timesRedeemed: number
createdAt: string
}
function formatPromoCode(promo: {
id: string
code: string
coupon: {
id: string
name: string | null
percent_off: number | null
duration: string
duration_in_months: number | null
}
max_redemptions: number | null
expires_at: number | null
active: boolean
times_redeemed: number
created: number
}): PromoCodeResponse {
return {
id: promo.id,
code: promo.code,
couponId: promo.coupon.id,
name: promo.coupon.name ?? '',
percentOff: promo.coupon.percent_off ?? 0,
duration: promo.coupon.duration,
durationInMonths: promo.coupon.duration_in_months,
maxRedemptions: promo.max_redemptions,
expiresAt: promo.expires_at ? new Date(promo.expires_at * 1000).toISOString() : null,
active: promo.active,
timesRedeemed: promo.times_redeemed,
createdAt: new Date(promo.created * 1000).toISOString(),
}
}
const logger = createLogger('AdminReferralCampaignsAPI')
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
const activeFilter = url.searchParams.get('active')
try {
const stripe = requireStripeClient()
const url = new URL(request.url)
const conditions: SQL<unknown>[] = []
if (activeFilter === 'true') {
conditions.push(eq(referralCampaigns.isActive, true))
} else if (activeFilter === 'false') {
conditions.push(eq(referralCampaigns.isActive, false))
}
const limitParam = url.searchParams.get('limit')
let limit = limitParam ? Number.parseInt(limitParam, 10) : 50
if (Number.isNaN(limit) || limit < 1) limit = 50
if (limit > 100) limit = 100
const whereClause = conditions.length > 0 ? conditions[0] : undefined
const baseUrl = getBaseUrl()
const startingAfter = url.searchParams.get('starting_after') || undefined
const activeFilter = url.searchParams.get('active')
const [countResult, campaigns] = await Promise.all([
db.select({ total: count() }).from(referralCampaigns).where(whereClause),
db
.select()
.from(referralCampaigns)
.where(whereClause)
.orderBy(referralCampaigns.createdAt)
.limit(limit)
.offset(offset),
])
const listParams: Record<string, unknown> = { limit }
if (startingAfter) listParams.starting_after = startingAfter
if (activeFilter === 'true') listParams.active = true
else if (activeFilter === 'false') listParams.active = false
const total = countResult[0].total
const data: AdminReferralCampaign[] = campaigns.map((c) => toAdminReferralCampaign(c, baseUrl))
const pagination = createPaginationMeta(total, limit, offset)
const promoCodes = await stripe.promotionCodes.list(listParams)
logger.info(`Admin API: Listed ${data.length} referral campaigns (total: ${total})`)
const data = promoCodes.data.map(formatPromoCode)
logger.info(`Admin API: Listed ${data.length} Stripe promotion codes`)
return NextResponse.json({
data,
hasMore: promoCodes.has_more,
...(data.length > 0 ? { nextCursor: data[data.length - 1].id } : {}),
})
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list promotion codes', { error })
return internalErrorResponse('Failed to list promotion codes')
logger.error('Admin API: Failed to list referral campaigns', { error })
return internalErrorResponse('Failed to list referral campaigns')
}
})
export const POST = withAdminAuth(async (request) => {
try {
const stripe = requireStripeClient()
const body = await request.json()
const { name, code, utmSource, utmMedium, utmCampaign, utmContent, bonusCreditAmount } = body
const { name, percentOff, code, duration, durationInMonths, maxRedemptions, expiresAt } = body
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return badRequestResponse('name is required and must be a non-empty string')
if (!name || typeof name !== 'string') {
return badRequestResponse('name is required and must be a string')
}
if (
typeof percentOff !== 'number' ||
!Number.isFinite(percentOff) ||
percentOff < 1 ||
percentOff > 100
typeof bonusCreditAmount !== 'number' ||
!Number.isFinite(bonusCreditAmount) ||
bonusCreditAmount <= 0
) {
return badRequestResponse('percentOff must be a number between 1 and 100')
}
const effectiveDuration: Duration = duration ?? 'once'
if (!VALID_DURATIONS.includes(effectiveDuration)) {
return badRequestResponse(`duration must be one of: ${VALID_DURATIONS.join(', ')}`)
}
if (effectiveDuration === 'repeating') {
if (
typeof durationInMonths !== 'number' ||
!Number.isInteger(durationInMonths) ||
durationInMonths < 1
) {
return badRequestResponse(
'durationInMonths is required and must be a positive integer when duration is "repeating"'
)
}
return badRequestResponse('bonusCreditAmount must be a positive number')
}
if (code !== undefined && code !== null) {
@@ -166,77 +110,31 @@ export const POST = withAdminAuth(async (request) => {
}
}
if (maxRedemptions !== undefined && maxRedemptions !== null) {
if (
typeof maxRedemptions !== 'number' ||
!Number.isInteger(maxRedemptions) ||
maxRedemptions < 1
) {
return badRequestResponse('maxRedemptions must be a positive integer')
}
}
const id = nanoid()
if (expiresAt !== undefined && expiresAt !== null) {
const parsed = new Date(expiresAt)
if (Number.isNaN(parsed.getTime())) {
return badRequestResponse('expiresAt must be a valid ISO 8601 date string')
}
if (parsed.getTime() <= Date.now()) {
return badRequestResponse('expiresAt must be in the future')
}
}
const [campaign] = await db
.insert(referralCampaigns)
.values({
id,
name,
code: code ? code.trim().toUpperCase() : null,
utmSource: utmSource || null,
utmMedium: utmMedium || null,
utmCampaign: utmCampaign || null,
utmContent: utmContent || null,
bonusCreditAmount: bonusCreditAmount.toString(),
})
.returning()
const coupon = await stripe.coupons.create({
name: name.trim(),
percent_off: percentOff,
duration: effectiveDuration,
...(effectiveDuration === 'repeating' ? { duration_in_months: durationInMonths } : {}),
logger.info(`Admin API: Created referral campaign ${id}`, {
name,
code: campaign.code,
bonusCreditAmount,
})
let promoCode
try {
const promoParams: Stripe.PromotionCodeCreateParams = {
coupon: coupon.id,
...(code ? { code: code.trim().toUpperCase() } : {}),
...(maxRedemptions ? { max_redemptions: maxRedemptions } : {}),
...(expiresAt ? { expires_at: Math.floor(new Date(expiresAt).getTime() / 1000) } : {}),
}
promoCode = await stripe.promotionCodes.create(promoParams)
} catch (promoError) {
try {
await stripe.coupons.del(coupon.id)
} catch (cleanupError) {
logger.error(
'Admin API: Failed to clean up orphaned coupon after promo code creation failed',
{
couponId: coupon.id,
cleanupError,
}
)
}
throw promoError
}
logger.info('Admin API: Created Stripe promotion code', {
promoCodeId: promoCode.id,
code: promoCode.code,
couponId: coupon.id,
percentOff,
duration: effectiveDuration,
})
return singleResponse(formatPromoCode(promoCode))
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
} catch (error) {
if (
error instanceof Error &&
'type' in error &&
(error as { type: string }).type === 'StripeInvalidRequestError'
) {
logger.warn('Admin API: Stripe rejected promotion code request', { error: error.message })
return badRequestResponse(error.message)
}
logger.error('Admin API: Failed to create promotion code', { error })
return internalErrorResponse('Failed to create promotion code')
logger.error('Admin API: Failed to create referral campaign', { error })
return internalErrorResponse('Failed to create referral campaign')
}
})

View File

@@ -9,6 +9,7 @@ import type {
auditLog,
member,
organization,
referralCampaigns,
subscription,
user,
userStats,
@@ -32,6 +33,7 @@ export type DbOrganization = InferSelectModel<typeof organization>
export type DbSubscription = InferSelectModel<typeof subscription>
export type DbMember = InferSelectModel<typeof member>
export type DbUserStats = InferSelectModel<typeof userStats>
export type DbReferralCampaign = InferSelectModel<typeof referralCampaigns>
// =============================================================================
// Pagination
@@ -648,6 +650,52 @@ export interface AdminUndeployResult {
isDeployed: boolean
}
// =============================================================================
// Referral Campaign Types
// =============================================================================
export interface AdminReferralCampaign {
id: string
name: string
code: string | null
utmSource: string | null
utmMedium: string | null
utmCampaign: string | null
utmContent: string | null
bonusCreditAmount: string
isActive: boolean
signupUrl: string | null
createdAt: string
updatedAt: string
}
export function toAdminReferralCampaign(
dbCampaign: DbReferralCampaign,
baseUrl: string
): AdminReferralCampaign {
const utmParams = new URLSearchParams()
if (dbCampaign.utmSource) utmParams.set('utm_source', dbCampaign.utmSource)
if (dbCampaign.utmMedium) utmParams.set('utm_medium', dbCampaign.utmMedium)
if (dbCampaign.utmCampaign) utmParams.set('utm_campaign', dbCampaign.utmCampaign)
if (dbCampaign.utmContent) utmParams.set('utm_content', dbCampaign.utmContent)
const query = utmParams.toString()
return {
id: dbCampaign.id,
name: dbCampaign.name,
code: dbCampaign.code,
utmSource: dbCampaign.utmSource,
utmMedium: dbCampaign.utmMedium,
utmCampaign: dbCampaign.utmCampaign,
utmContent: dbCampaign.utmContent,
bonusCreditAmount: dbCampaign.bonusCreditAmount,
isActive: dbCampaign.isActive,
signupUrl: query ? `${baseUrl}/signup?${query}` : null,
createdAt: dbCampaign.createdAt.toISOString(),
updatedAt: dbCampaign.updatedAt.toISOString(),
}
}
// =============================================================================
// Audit Log Types
// =============================================================================

View File

@@ -20,10 +20,6 @@ import {
} from '@/lib/execution/call-chain'
import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer'
import { processInputFileFields } from '@/lib/execution/files'
import {
registerManualExecutionAborter,
unregisterManualExecutionAborter,
} from '@/lib/execution/manual-cancellation'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import {
@@ -849,7 +845,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const encoder = new TextEncoder()
const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync)
let isStreamClosed = false
let isManualAbortRegistered = false
const eventWriter = createExecutionEventWriter(executionId)
setExecutionMeta(executionId, {
@@ -862,9 +857,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
async start(controller) {
let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null
registerManualExecutionAborter(executionId, timeoutController.abort)
isManualAbortRegistered = true
const sendEvent = (event: ExecutionEvent) => {
if (!isStreamClosed) {
try {
@@ -1232,10 +1224,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
})
finalMetaStatus = 'error'
} finally {
if (isManualAbortRegistered) {
unregisterManualExecutionAborter(executionId)
isManualAbortRegistered = false
}
try {
await eventWriter.close()
} catch (closeError) {

View File

@@ -1,148 +0,0 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockCheckHybridAuth = vi.fn()
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
const mockMarkExecutionCancelled = vi.fn()
const mockAbortManualExecution = vi.fn()
vi.mock('@sim/logger', () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkHybridAuth: (...args: unknown[]) => mockCheckHybridAuth(...args),
}))
vi.mock('@/lib/execution/cancellation', () => ({
markExecutionCancelled: (...args: unknown[]) => mockMarkExecutionCancelled(...args),
}))
vi.mock('@/lib/execution/manual-cancellation', () => ({
abortManualExecution: (...args: unknown[]) => mockAbortManualExecution(...args),
}))
vi.mock('@/lib/workflows/utils', () => ({
authorizeWorkflowByWorkspacePermission: (params: unknown) =>
mockAuthorizeWorkflowByWorkspacePermission(params),
}))
import { POST } from './route'
describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-1' })
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true })
mockAbortManualExecution.mockReturnValue(false)
})
it('returns success when cancellation was durably recorded', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: true,
reason: 'recorded',
})
const response = await POST(
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
method: 'POST',
}),
{
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
}
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
executionId: 'ex-1',
redisAvailable: true,
durablyRecorded: true,
locallyAborted: false,
reason: 'recorded',
})
})
it('returns unsuccessful response when Redis is unavailable', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: 'redis_unavailable',
})
const response = await POST(
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
method: 'POST',
}),
{
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
}
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: false,
executionId: 'ex-1',
redisAvailable: false,
durablyRecorded: false,
locallyAborted: false,
reason: 'redis_unavailable',
})
})
it('returns unsuccessful response when Redis persistence fails', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: 'redis_write_failed',
})
const response = await POST(
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
method: 'POST',
}),
{
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
}
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: false,
executionId: 'ex-1',
redisAvailable: true,
durablyRecorded: false,
locallyAborted: false,
reason: 'redis_write_failed',
})
})
it('returns success when local fallback aborts execution without Redis durability', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: 'redis_unavailable',
})
mockAbortManualExecution.mockReturnValue(true)
const response = await POST(
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
method: 'POST',
}),
{
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
}
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
executionId: 'ex-1',
redisAvailable: false,
durablyRecorded: false,
locallyAborted: true,
reason: 'redis_unavailable',
})
})
})

View File

@@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
const logger = createLogger('CancelExecutionAPI')
@@ -46,27 +45,20 @@ export async function POST(
logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId })
const cancellation = await markExecutionCancelled(executionId)
const locallyAborted = abortManualExecution(executionId)
const marked = await markExecutionCancelled(executionId)
if (cancellation.durablyRecorded) {
if (marked) {
logger.info('Execution marked as cancelled in Redis', { executionId })
} else if (locallyAborted) {
logger.info('Execution cancelled via local in-process fallback', { executionId })
} else {
logger.warn('Execution cancellation was not durably recorded', {
logger.info('Redis not available, cancellation will rely on connection close', {
executionId,
reason: cancellation.reason,
})
}
return NextResponse.json({
success: cancellation.durablyRecorded || locallyAborted,
success: true,
executionId,
redisAvailable: cancellation.reason !== 'redis_unavailable',
durablyRecorded: cancellation.durablyRecorded,
locallyAborted,
reason: cancellation.reason,
redisAvailable: marked,
})
} catch (error: any) {
logger.error('Failed to cancel execution', { workflowId, executionId, error: error.message })

View File

@@ -87,33 +87,32 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
const formData = await request.formData()
const rawFile = formData.get('file')
const file = formData.get('file') as File
if (!rawFile || !(rawFile instanceof File)) {
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
const fileName = rawFile.name || 'untitled'
// Validate file size (100MB limit)
const maxSize = 100 * 1024 * 1024
if (rawFile.size > maxSize) {
if (file.size > maxSize) {
return NextResponse.json(
{ error: `File size exceeds 100MB limit (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)` },
{ error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` },
{ status: 400 }
)
}
const buffer = Buffer.from(await rawFile.arrayBuffer())
const buffer = Buffer.from(await file.arrayBuffer())
const userFile = await uploadWorkspaceFile(
workspaceId,
session.user.id,
buffer,
fileName,
rawFile.type || 'application/octet-stream'
file.name,
file.type || 'application/octet-stream'
)
logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`)
logger.info(`[${requestId}] Uploaded workspace file: ${file.name}`)
recordAudit({
workspaceId,
@@ -123,8 +122,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
action: AuditAction.FILE_UPLOADED,
resourceType: AuditResourceType.FILE,
resourceId: userFile.id,
resourceName: fileName,
description: `Uploaded file "${fileName}"`,
resourceName: file.name,
description: `Uploaded file "${file.name}"`,
request,
})

View File

@@ -1,6 +1,6 @@
'use client'
import { Skeleton } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
export function ChatLoadingState() {
return (

View File

@@ -1,6 +1,6 @@
'use client'
import { Skeleton } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import AuthBackground from '@/app/(auth)/components/auth-background'
export function FormLoadingState() {

View File

@@ -114,7 +114,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
if (isCollapsed) {
document.documentElement.style.setProperty('--sidebar-width', '51px');
document.documentElement.setAttribute('data-sidebar-collapsed', '');
} else {
var width = state && state.sidebarWidth;
var maxSidebarWidth = window.innerWidth * 0.3;

View File

@@ -27,8 +27,8 @@ import {
PopoverContent,
PopoverItem,
PopoverTrigger,
Skeleton,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import { VerifiedBadge } from '@/components/ui/verified-badge'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'

View File

@@ -1,6 +1,6 @@
'use client'
import { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react'
import { lazy, Suspense, useCallback, useEffect, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { Square } from 'lucide-react'
import { useRouter } from 'next/navigation'
@@ -51,11 +51,7 @@ interface ResourceContentProps {
* Handles table, file, and workflow resource types with appropriate
* embedded rendering for each.
*/
export const ResourceContent = memo(function ResourceContent({
workspaceId,
resource,
previewMode,
}: ResourceContentProps) {
export function ResourceContent({ workspaceId, resource, previewMode }: ResourceContentProps) {
switch (resource.type) {
case 'table':
return <Table key={resource.id} workspaceId={workspaceId} tableId={resource.id} embedded />
@@ -88,7 +84,7 @@ export const ResourceContent = memo(function ResourceContent({
default:
return null
}
})
}
interface ResourceActionsProps {
workspaceId: string
@@ -307,12 +303,10 @@ interface EmbeddedWorkflowProps {
function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) {
const workflowExists = useWorkflowRegistry((state) => Boolean(state.workflows[workflowId]))
const isMetadataLoaded = useWorkflowRegistry(
(state) => state.hydration.phase !== 'idle' && state.hydration.phase !== 'metadata-loading'
)
const hasLoadError = useWorkflowRegistry(
(state) => state.hydration.phase === 'error' && state.hydration.workflowId === workflowId
)
const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
const hydrationWorkflowId = useWorkflowRegistry((state) => state.hydration.workflowId)
const isMetadataLoaded = hydrationPhase !== 'idle' && hydrationPhase !== 'metadata-loading'
const hasLoadError = hydrationPhase === 'error' && hydrationWorkflowId === workflowId
if (!isMetadataLoaded) return LOADING_SKELETON

View File

@@ -1,6 +1,6 @@
'use client'
import { memo, useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { cn } from '@/lib/core/utils/cn'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
@@ -34,7 +34,7 @@ interface MothershipViewProps {
className?: string
}
export const MothershipView = memo(function MothershipView({
export function MothershipView({
workspaceId,
chatId,
resources,
@@ -99,4 +99,4 @@ export const MothershipView = memo(function MothershipView({
</div>
</div>
)
})
}

View File

@@ -212,7 +212,6 @@ export function UserInput({
const files = useFileAttachments({
userId: userId || session?.user?.id,
workspaceId,
disabled: false,
isLoading: isSending,
})

View File

@@ -16,7 +16,6 @@ import {
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
import type { ChatContext } from '@/stores/panel'
import { useSidebarStore } from '@/stores/sidebar/store'
import {
MessageContent,
MothershipView,
@@ -167,8 +166,6 @@ export function Home({ chatId }: HomeProps = {}) {
const handleResourceEvent = useCallback(() => {
if (isResourceCollapsedRef.current) {
const { isCollapsed, toggleCollapsed } = useSidebarStore.getState()
if (!isCollapsed) toggleCollapsed()
setIsResourceCollapsed(false)
setIsResourceAnimatingIn(true)
}

View File

@@ -582,7 +582,8 @@ export function useChat(
readArgs?.path as string | undefined,
tc.result.output
)
if (resource && addResource(resource)) {
if (resource) {
addResource(resource)
onResourceEventRef.current?.()
}
}
@@ -593,21 +594,12 @@ export function useChat(
case 'resource_added': {
const resource = parsed.resource
if (resource?.type && resource?.id) {
const wasAdded = addResource(resource)
addResource(resource)
invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id)
if (!wasAdded && activeResourceIdRef.current !== resource.id) {
setActiveResourceId(resource.id)
}
onResourceEventRef.current?.()
if (resource.type === 'workflow') {
const wasRegistered = ensureWorkflowInRegistry(
resource.id,
resource.title,
workspaceId
)
if (wasAdded && wasRegistered) {
if (ensureWorkflowInRegistry(resource.id, resource.title, workspaceId)) {
useWorkflowRegistry.getState().setActiveWorkflow(resource.id)
} else {
useWorkflowRegistry.getState().loadWorkflowState(resource.id)
@@ -888,15 +880,12 @@ export function useChat(
try {
const currentActiveId = activeResourceIdRef.current
const currentResources = resourcesRef.current
const resourceAttachments =
currentResources.length > 0
? currentResources.map((r) => ({
type: r.type,
id: r.id,
title: r.title,
active: r.id === currentActiveId,
}))
: undefined
const activeRes = currentActiveId
? currentResources.find((r) => r.id === currentActiveId)
: undefined
const resourceAttachments = activeRes
? [{ type: activeRes.type, id: activeRes.id }]
: undefined
const response = await fetch(MOTHERSHIP_CHAT_API_PATH, {
method: 'POST',

View File

@@ -23,9 +23,9 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Skeleton,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/core/utils/cn'
import {
getCanonicalScopesForProvider,

View File

@@ -19,8 +19,8 @@ import {
ModalTabsContent,
ModalTabsList,
ModalTabsTrigger,
Skeleton,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
import type { ConnectorConfig } from '@/connectors/types'
import type { ConnectorData } from '@/hooks/queries/kb/connectors'

View File

@@ -2,7 +2,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Skeleton } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
import type { DashboardStatsResponse, WorkflowStats } from '@/hooks/queries/logs'
import { useFilterStore } from '@/stores/logs/filters/store'

View File

@@ -16,6 +16,7 @@ import {
Tooltip,
} from '@/components/emcn'
import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons'
import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
@@ -395,7 +396,7 @@ export const LogDetails = memo(function LogDetails({
</div>
{/* Content - Scrollable */}
<div className='mt-[20px] h-full w-full overflow-y-auto' ref={scrollAreaRef}>
<ScrollArea className='mt-[20px] h-full w-full overflow-y-auto' ref={scrollAreaRef}>
<div className='flex flex-col gap-[10px] pb-[16px]'>
{/* Timestamp & Workflow Row */}
<div className='flex min-w-0 items-center gap-[16px] px-[1px]'>
@@ -631,7 +632,7 @@ export const LogDetails = memo(function LogDetails({
</div>
)}
</div>
</div>
</ScrollArea>
</div>
)}

View File

@@ -2,7 +2,8 @@
import { useMemo } from 'react'
import { X } from 'lucide-react'
import { Badge, Combobox, type ComboboxOption, Label, Skeleton } from '@/components/emcn'
import { Badge, Combobox, type ComboboxOption, Label } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { useWorkflows } from '@/hooks/queries/workflows'
interface WorkflowSelectorProps {

View File

@@ -18,11 +18,11 @@ import {
ModalTabsContent,
ModalTabsList,
ModalTabsTrigger,
Skeleton,
TagInput,
type TagItem,
} from '@/components/emcn'
import { SlackIcon } from '@/components/icons'
import { Skeleton } from '@/components/ui'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import { quickValidateEmail } from '@/lib/messaging/email/validation'

View File

@@ -11,11 +11,10 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Skeleton,
Switch,
Tooltip,
} from '@/components/emcn'
import { Input } from '@/components/ui'
import { Input, Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { formatDate } from '@/lib/core/utils/formatting'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'

View File

@@ -17,12 +17,11 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Skeleton,
TagInput,
type TagItem,
} from '@/components/emcn'
import { GmailIcon, OutlookIcon } from '@/components/icons'
import { Input as BaseInput } from '@/components/ui'
import { Input as BaseInput, Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionStatus } from '@/lib/billing/client'
import { cn } from '@/lib/core/utils/cn'

View File

@@ -17,12 +17,11 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Skeleton,
Textarea,
Tooltip,
Trash,
} from '@/components/emcn'
import { Input } from '@/components/ui'
import { Input, Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import {
clearPendingCredentialCreateRequest,

View File

@@ -17,11 +17,10 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Skeleton,
Textarea,
Tooltip,
} from '@/components/emcn'
import { Input as UiInput } from '@/components/ui'
import { Skeleton, Input as UiInput } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import {
clearPendingCredentialCreateRequest,

View File

@@ -1,2 +1,3 @@
export { CreditBalance } from './credit-balance'
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
export { ReferralCode } from './referral-code'

View File

@@ -0,0 +1 @@
export { ReferralCode } from './referral-code'

View File

@@ -0,0 +1,82 @@
'use client'
import { useState } from 'react'
import { Button, Input, Label } from '@/components/emcn'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { useRedeemReferralCode } from '@/hooks/queries/subscription'
interface ReferralCodeProps {
onRedeemComplete?: () => void
}
/**
* Inline referral/promo code entry field with redeem button.
* One-time use per account — shows success or "already redeemed" state.
*/
export function ReferralCode({ onRedeemComplete }: ReferralCodeProps) {
const [code, setCode] = useState('')
const redeemCode = useRedeemReferralCode()
const handleRedeem = () => {
const trimmed = code.trim()
if (!trimmed || redeemCode.isPending) return
redeemCode.mutate(
{ code: trimmed },
{
onSuccess: () => {
setCode('')
onRedeemComplete?.()
},
}
)
}
if (redeemCode.isSuccess) {
return (
<div className='flex items-center justify-between'>
<Label>Referral Code</Label>
<span className='text-[13px] text-[var(--text-secondary)]'>
+{dollarsToCredits(redeemCode.data.bonusAmount ?? 0).toLocaleString()} credits applied
</span>
</div>
)
}
return (
<div className='flex flex-col gap-[4px]'>
<div className='flex items-center justify-between gap-[12px]'>
<Label className='shrink-0'>Referral Code</Label>
<div className='flex items-center gap-[8px]'>
<Input
type='text'
value={code}
onChange={(e) => {
setCode(e.target.value)
redeemCode.reset()
}}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRedeem()
}}
placeholder='Enter code'
className='h-[32px] w-[140px] bg-[var(--surface-4)] text-[13px]'
disabled={redeemCode.isPending}
/>
<Button
variant='default'
className='h-[32px] shrink-0 text-[13px]'
onClick={handleRedeem}
disabled={redeemCode.isPending || !code.trim()}
>
{redeemCode.isPending ? 'Redeeming...' : 'Redeem'}
</Button>
</div>
</div>
{redeemCode.error && (
<span className='text-right text-[11px] text-[var(--text-error)]'>
{redeemCode.error.message}
</span>
)}
</div>
)
}

View File

@@ -14,10 +14,10 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Skeleton,
Switch,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { useSession, useSubscription } from '@/lib/auth/auth-client'
import { USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
@@ -47,6 +47,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
import {
CreditBalance,
PlanCard,
ReferralCode,
} from '@/app/workspace/[workspaceId]/settings/components/subscription/components'
import {
ENTERPRISE_PLAN_FEATURES,
@@ -999,6 +1000,11 @@ export function Subscription() {
inlineButton
/>
)}
{/* Referral Code */}
{!subscription.isEnterprise && (
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
)}
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { Badge, Button, Skeleton } from '@/components/emcn'
import { Badge, Button } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
import { cn } from '@/lib/core/utils/cn'

View File

@@ -2,7 +2,8 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Skeleton, type TagItem } from '@/components/emcn'
import type { TagItem } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { getPlanTierCredits, getPlanTierDollars } from '@/lib/billing/plan-helpers'
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'

View File

@@ -4,8 +4,9 @@ import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Camera, Globe, Linkedin, Mail } from 'lucide-react'
import Image from 'next/image'
import { Button, Combobox, Input, Skeleton, Textarea } from '@/components/emcn'
import { Button, Combobox, Input, Textarea } from '@/components/emcn'
import { AgentIcon, xIcon as XIcon } from '@/components/icons'
import { Skeleton } from '@/components/ui/skeleton'
import { useSession } from '@/lib/auth/auth-client'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import type { CreatorProfileDetails } from '@/app/_types/creator-profile'

View File

@@ -19,7 +19,6 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Skeleton,
SModalTabs,
SModalTabsBody,
SModalTabsContent,
@@ -28,7 +27,7 @@ import {
Textarea,
Tooltip,
} from '@/components/emcn'
import { Input } from '@/components/ui'
import { Input, Skeleton } from '@/components/ui'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useApiKeys } from '@/hooks/queries/api-keys'

View File

@@ -43,7 +43,6 @@ export interface MessageFileAttachment {
interface UseFileAttachmentsProps {
userId?: string
workspaceId?: string
disabled?: boolean
isLoading?: boolean
}
@@ -56,7 +55,7 @@ interface UseFileAttachmentsProps {
* @returns File attachment state and operations
*/
export function useFileAttachments(props: UseFileAttachmentsProps) {
const { userId, workspaceId, disabled, isLoading } = props
const { userId, disabled, isLoading } = props
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([])
const [isDragging, setIsDragging] = useState(false)
@@ -136,10 +135,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
try {
const formData = new FormData()
formData.append('file', file)
formData.append('context', 'mothership')
if (workspaceId) {
formData.append('workspaceId', workspaceId)
}
formData.append('context', 'copilot')
const uploadResponse = await fetch('/api/files/upload', {
method: 'POST',
@@ -175,7 +171,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
}
}
},
[userId, workspaceId]
[userId]
)
/**

View File

@@ -188,7 +188,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const fileAttachments = useFileAttachments({
userId: session?.user?.id,
workspaceId,
disabled,
isLoading,
})

View File

@@ -12,11 +12,11 @@ import {
Code,
Input,
Label,
Skeleton,
TagInput,
Textarea,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import type { AgentAuthentication, AgentCapabilities } from '@/lib/a2a/types'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'

View File

@@ -9,9 +9,9 @@ import {
Code,
Combobox,
Label,
Skeleton,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
interface WorkflowDeploymentInfo {

View File

@@ -14,13 +14,12 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Skeleton,
TagInput,
type TagItem,
Textarea,
Tooltip,
} from '@/components/emcn'
import { Alert, AlertDescription } from '@/components/ui'
import { Alert, AlertDescription, Skeleton } from '@/components/ui'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { generatePassword } from '@/lib/core/security/encryption'
import { cn } from '@/lib/core/utils/cn'

View File

@@ -8,12 +8,12 @@ import {
ButtonGroupItem,
Input,
Label,
Skeleton,
TagInput,
type TagItem,
Textarea,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { isDev } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'

View File

@@ -9,9 +9,9 @@ import {
PopoverContent,
PopoverItem,
PopoverTrigger,
Skeleton,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { formatDateTime } from '@/lib/core/utils/formatting'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { useUpdateDeploymentVersion } from '@/hooks/queries/deployments'

View File

@@ -13,9 +13,9 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Skeleton,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { Preview, PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'

View File

@@ -10,9 +10,9 @@ import {
type ComboboxOption,
Input,
Label,
Skeleton,
Textarea,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'

View File

@@ -12,10 +12,10 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Skeleton,
TagInput,
Textarea,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'

View File

@@ -4,6 +4,11 @@ import { useQueryClient } from '@tanstack/react-query'
import { v4 as uuidv4 } from 'uuid'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { processStreamingBlockLogs } from '@/lib/tokenization'
import type {
BlockCompletedData,
BlockErrorData,
BlockStartedData,
} from '@/lib/workflows/executor/execution-events'
import {
extractTriggerMockPayload,
selectBestTrigger,
@@ -16,14 +21,21 @@ import {
} from '@/lib/workflows/triggers/triggers'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import {
type BlockEventHandlerConfig,
createBlockEventHandlers,
markOutgoingEdgesFromOutput,
updateActiveBlockRefCount,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils'
import { getBlock } from '@/blocks'
import type { SerializableExecutionState } from '@/executor/execution/types'
import type { BlockLog, BlockState, ExecutionResult, StreamingExecution } from '@/executor/types'
import type {
BlockLog,
BlockState,
ExecutionResult,
NormalizedBlockOutput,
StreamingExecution,
} from '@/executor/types'
import { hasExecutionResult } from '@/executor/utils/errors'
import { coerceValue } from '@/executor/utils/start-block'
import { stripCloneSuffixes } from '@/executor/utils/subflow-utils'
import { subscriptionKeys } from '@/hooks/queries/subscription'
import { useExecutionStream } from '@/hooks/use-execution-stream'
import { WorkflowValidationError } from '@/serializer'
@@ -51,6 +63,20 @@ interface DebugValidationResult {
error?: string
}
interface BlockEventHandlerConfig {
workflowId?: string
executionIdRef: { current: string }
workflowEdges: Array<{ id: string; source: string; target: string; sourceHandle?: string | null }>
activeBlocksSet: Set<string>
activeBlockRefCounts: Map<string, number>
accumulatedBlockLogs: BlockLog[]
accumulatedBlockStates: Map<string, BlockState>
executedBlockIds: Set<string>
consoleMode: 'update' | 'add'
includeStartConsoleEntry: boolean
onBlockCompleteCallback?: (blockId: string, output: unknown) => Promise<void>
}
const WORKFLOW_EXECUTION_FAILURE_MESSAGE = 'Workflow execution failed'
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -283,15 +309,279 @@ export function useWorkflowExecution() {
)
const buildBlockEventHandlers = useCallback(
(config: BlockEventHandlerConfig) =>
createBlockEventHandlers(config, {
addConsole,
updateConsole,
setActiveBlocks,
setBlockRunStatus,
setEdgeRunStatus,
}),
[addConsole, updateConsole, setActiveBlocks, setBlockRunStatus, setEdgeRunStatus]
(config: BlockEventHandlerConfig) => {
const {
workflowId,
executionIdRef,
workflowEdges,
activeBlocksSet,
activeBlockRefCounts,
accumulatedBlockLogs,
accumulatedBlockStates,
executedBlockIds,
consoleMode,
includeStartConsoleEntry,
onBlockCompleteCallback,
} = config
/** Returns true if this execution was cancelled or superseded by another run. */
const isStaleExecution = () =>
!!(
workflowId &&
executionIdRef.current &&
useExecutionStore.getState().getCurrentExecutionId(workflowId) !== executionIdRef.current
)
const updateActiveBlocks = (blockId: string, isActive: boolean) => {
if (!workflowId) return
updateActiveBlockRefCount(activeBlockRefCounts, activeBlocksSet, blockId, isActive)
setActiveBlocks(workflowId, new Set(activeBlocksSet))
}
const markOutgoingEdges = (blockId: string, output: Record<string, any> | undefined) => {
if (!workflowId) return
markOutgoingEdgesFromOutput(blockId, output, workflowEdges, workflowId, setEdgeRunStatus)
}
const isContainerBlockType = (blockType?: string) => {
return blockType === 'loop' || blockType === 'parallel'
}
/** Extracts iteration and child-workflow fields shared across console entry call sites. */
const extractIterationFields = (
data: BlockStartedData | BlockCompletedData | BlockErrorData
) => ({
iterationCurrent: data.iterationCurrent,
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
iterationContainerId: data.iterationContainerId,
parentIterations: data.parentIterations,
childWorkflowBlockId: data.childWorkflowBlockId,
childWorkflowName: data.childWorkflowName,
...('childWorkflowInstanceId' in data && {
childWorkflowInstanceId: data.childWorkflowInstanceId,
}),
})
const createBlockLogEntry = (
data: BlockCompletedData | BlockErrorData,
options: { success: boolean; output?: unknown; error?: string }
): BlockLog => ({
blockId: data.blockId,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
input: data.input || {},
output: options.output ?? {},
success: options.success,
error: options.error,
durationMs: data.durationMs,
startedAt: data.startedAt,
executionOrder: data.executionOrder,
endedAt: data.endedAt,
})
const addConsoleEntry = (data: BlockCompletedData, output: NormalizedBlockOutput) => {
if (!workflowId) return
addConsole({
input: data.input || {},
output,
success: true,
durationMs: data.durationMs,
startedAt: data.startedAt,
executionOrder: data.executionOrder,
endedAt: data.endedAt,
workflowId,
blockId: data.blockId,
executionId: executionIdRef.current,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
...extractIterationFields(data),
})
}
const addConsoleErrorEntry = (data: BlockErrorData) => {
if (!workflowId) return
addConsole({
input: data.input || {},
output: {},
success: false,
error: data.error,
durationMs: data.durationMs,
startedAt: data.startedAt,
executionOrder: data.executionOrder,
endedAt: data.endedAt,
workflowId,
blockId: data.blockId,
executionId: executionIdRef.current,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
...extractIterationFields(data),
})
}
const updateConsoleEntry = (data: BlockCompletedData) => {
updateConsole(
data.blockId,
{
executionOrder: data.executionOrder,
input: data.input || {},
replaceOutput: data.output,
success: true,
durationMs: data.durationMs,
startedAt: data.startedAt,
endedAt: data.endedAt,
isRunning: false,
...extractIterationFields(data),
},
executionIdRef.current
)
}
const updateConsoleErrorEntry = (data: BlockErrorData) => {
updateConsole(
data.blockId,
{
executionOrder: data.executionOrder,
input: data.input || {},
replaceOutput: {},
success: false,
error: data.error,
durationMs: data.durationMs,
startedAt: data.startedAt,
endedAt: data.endedAt,
isRunning: false,
...extractIterationFields(data),
},
executionIdRef.current
)
}
const onBlockStarted = (data: BlockStartedData) => {
if (isStaleExecution()) return
updateActiveBlocks(data.blockId, true)
if (!includeStartConsoleEntry || !workflowId) return
const startedAt = new Date().toISOString()
addConsole({
input: {},
output: undefined,
success: undefined,
durationMs: undefined,
startedAt,
executionOrder: data.executionOrder,
endedAt: undefined,
workflowId,
blockId: data.blockId,
executionId: executionIdRef.current,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
isRunning: true,
...extractIterationFields(data),
})
}
const onBlockCompleted = (data: BlockCompletedData) => {
if (isStaleExecution()) return
updateActiveBlocks(data.blockId, false)
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'success')
markOutgoingEdges(data.blockId, data.output as Record<string, any> | undefined)
executedBlockIds.add(data.blockId)
accumulatedBlockStates.set(data.blockId, {
output: data.output,
executed: true,
executionTime: data.durationMs,
})
// For nested containers, the SSE blockId may be a cloned ID (e.g. P1__obranch-0).
// Also record the original workflow-level ID so the canvas can highlight it.
if (isContainerBlockType(data.blockType)) {
const originalId = stripCloneSuffixes(data.blockId)
if (originalId !== data.blockId) {
executedBlockIds.add(originalId)
if (workflowId) setBlockRunStatus(workflowId, originalId, 'success')
}
}
if (isContainerBlockType(data.blockType) && !data.iterationContainerId) {
const output = data.output as Record<string, any> | undefined
const isEmptySubflow = Array.isArray(output?.results) && output.results.length === 0
if (!isEmptySubflow) return
}
accumulatedBlockLogs.push(createBlockLogEntry(data, { success: true, output: data.output }))
if (consoleMode === 'update') {
updateConsoleEntry(data)
} else {
addConsoleEntry(data, data.output as NormalizedBlockOutput)
}
if (onBlockCompleteCallback) {
onBlockCompleteCallback(data.blockId, data.output).catch((error) => {
logger.error('Error in onBlockComplete callback:', error)
})
}
}
const onBlockError = (data: BlockErrorData) => {
if (isStaleExecution()) return
updateActiveBlocks(data.blockId, false)
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'error')
markOutgoingEdges(data.blockId, { error: data.error })
executedBlockIds.add(data.blockId)
accumulatedBlockStates.set(data.blockId, {
output: { error: data.error },
executed: true,
executionTime: data.durationMs || 0,
})
// For nested containers, also record the original workflow-level ID
if (isContainerBlockType(data.blockType)) {
const originalId = stripCloneSuffixes(data.blockId)
if (originalId !== data.blockId) {
executedBlockIds.add(originalId)
if (workflowId) setBlockRunStatus(workflowId, originalId, 'error')
}
}
accumulatedBlockLogs.push(
createBlockLogEntry(data, { success: false, output: {}, error: data.error })
)
if (consoleMode === 'update') {
updateConsoleErrorEntry(data)
} else {
addConsoleErrorEntry(data)
}
}
const onBlockChildWorkflowStarted = (data: {
blockId: string
childWorkflowInstanceId: string
iterationCurrent?: number
iterationContainerId?: string
executionOrder?: number
}) => {
if (isStaleExecution()) return
updateConsole(
data.blockId,
{
childWorkflowInstanceId: data.childWorkflowInstanceId,
...(data.iterationCurrent !== undefined && { iterationCurrent: data.iterationCurrent }),
...(data.iterationContainerId !== undefined && {
iterationContainerId: data.iterationContainerId,
}),
...(data.executionOrder !== undefined && { executionOrder: data.executionOrder }),
},
executionIdRef.current
)
}
return { onBlockStarted, onBlockCompleted, onBlockError, onBlockChildWorkflowStarted }
},
[addConsole, setActiveBlocks, setBlockRunStatus, setEdgeRunStatus, updateConsole]
)
/**

View File

@@ -1,23 +1,6 @@
import { createLogger } from '@sim/logger'
import { v4 as uuidv4 } from 'uuid'
import type {
BlockCompletedData,
BlockErrorData,
BlockStartedData,
} from '@/lib/workflows/executor/execution-events'
import type {
BlockLog,
BlockState,
ExecutionResult,
NormalizedBlockOutput,
StreamingExecution,
} from '@/executor/types'
import { stripCloneSuffixes } from '@/executor/utils/subflow-utils'
const logger = createLogger('workflow-execution-utils')
import type { ExecutionResult, StreamingExecution } from '@/executor/types'
import { useExecutionStore } from '@/stores/execution'
import type { ConsoleEntry, ConsoleUpdate } from '@/stores/terminal'
import { useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -102,310 +85,6 @@ export function markOutgoingEdgesFromOutput(
}
}
export interface BlockEventHandlerConfig {
workflowId?: string
executionIdRef: { current: string }
workflowEdges: Array<{ id: string; source: string; target: string; sourceHandle?: string | null }>
activeBlocksSet: Set<string>
activeBlockRefCounts: Map<string, number>
accumulatedBlockLogs: BlockLog[]
accumulatedBlockStates: Map<string, BlockState>
executedBlockIds: Set<string>
consoleMode: 'update' | 'add'
includeStartConsoleEntry: boolean
onBlockCompleteCallback?: (blockId: string, output: unknown) => Promise<void>
}
export interface BlockEventHandlerDeps {
addConsole: (entry: Omit<ConsoleEntry, 'id' | 'timestamp'>) => ConsoleEntry
updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => void
setActiveBlocks: (workflowId: string, blocks: Set<string>) => void
setBlockRunStatus: (workflowId: string, blockId: string, status: 'success' | 'error') => void
setEdgeRunStatus: (workflowId: string, edgeId: string, status: 'success' | 'error') => void
}
/**
* Creates block event handlers for SSE execution events.
* Shared by the workflow execution hook and standalone execution utilities.
*/
export function createBlockEventHandlers(
config: BlockEventHandlerConfig,
deps: BlockEventHandlerDeps
) {
const {
workflowId,
executionIdRef,
workflowEdges,
activeBlocksSet,
activeBlockRefCounts,
accumulatedBlockLogs,
accumulatedBlockStates,
executedBlockIds,
consoleMode,
includeStartConsoleEntry,
onBlockCompleteCallback,
} = config
const { addConsole, updateConsole, setActiveBlocks, setBlockRunStatus, setEdgeRunStatus } = deps
const isStaleExecution = () =>
!!(
workflowId &&
executionIdRef.current &&
useExecutionStore.getState().getCurrentExecutionId(workflowId) !== executionIdRef.current
)
const updateActiveBlocks = (blockId: string, isActive: boolean) => {
if (!workflowId) return
updateActiveBlockRefCount(activeBlockRefCounts, activeBlocksSet, blockId, isActive)
setActiveBlocks(workflowId, new Set(activeBlocksSet))
}
const markOutgoingEdges = (blockId: string, output: Record<string, any> | undefined) => {
if (!workflowId) return
markOutgoingEdgesFromOutput(blockId, output, workflowEdges, workflowId, setEdgeRunStatus)
}
const isContainerBlockType = (blockType?: string) => {
return blockType === 'loop' || blockType === 'parallel'
}
const extractIterationFields = (
data: BlockStartedData | BlockCompletedData | BlockErrorData
) => ({
iterationCurrent: data.iterationCurrent,
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
iterationContainerId: data.iterationContainerId,
parentIterations: data.parentIterations,
childWorkflowBlockId: data.childWorkflowBlockId,
childWorkflowName: data.childWorkflowName,
...('childWorkflowInstanceId' in data && {
childWorkflowInstanceId: data.childWorkflowInstanceId,
}),
})
const createBlockLogEntry = (
data: BlockCompletedData | BlockErrorData,
options: { success: boolean; output?: unknown; error?: string }
): BlockLog => ({
blockId: data.blockId,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
input: data.input || {},
output: options.output ?? {},
success: options.success,
error: options.error,
durationMs: data.durationMs,
startedAt: data.startedAt,
executionOrder: data.executionOrder,
endedAt: data.endedAt,
})
const addConsoleEntry = (data: BlockCompletedData, output: NormalizedBlockOutput) => {
if (!workflowId) return
addConsole({
input: data.input || {},
output,
success: true,
durationMs: data.durationMs,
startedAt: data.startedAt,
executionOrder: data.executionOrder,
endedAt: data.endedAt,
workflowId,
blockId: data.blockId,
executionId: executionIdRef.current,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
...extractIterationFields(data),
})
}
const addConsoleErrorEntry = (data: BlockErrorData) => {
if (!workflowId) return
addConsole({
input: data.input || {},
output: {},
success: false,
error: data.error,
durationMs: data.durationMs,
startedAt: data.startedAt,
executionOrder: data.executionOrder,
endedAt: data.endedAt,
workflowId,
blockId: data.blockId,
executionId: executionIdRef.current,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
...extractIterationFields(data),
})
}
const updateConsoleEntry = (data: BlockCompletedData) => {
updateConsole(
data.blockId,
{
executionOrder: data.executionOrder,
input: data.input || {},
replaceOutput: data.output,
success: true,
durationMs: data.durationMs,
startedAt: data.startedAt,
endedAt: data.endedAt,
isRunning: false,
...extractIterationFields(data),
},
executionIdRef.current
)
}
const updateConsoleErrorEntry = (data: BlockErrorData) => {
updateConsole(
data.blockId,
{
executionOrder: data.executionOrder,
input: data.input || {},
replaceOutput: {},
success: false,
error: data.error,
durationMs: data.durationMs,
startedAt: data.startedAt,
endedAt: data.endedAt,
isRunning: false,
...extractIterationFields(data),
},
executionIdRef.current
)
}
const onBlockStarted = (data: BlockStartedData) => {
if (isStaleExecution()) return
updateActiveBlocks(data.blockId, true)
if (!includeStartConsoleEntry || !workflowId) return
const startedAt = new Date().toISOString()
addConsole({
input: {},
output: undefined,
success: undefined,
durationMs: undefined,
startedAt,
executionOrder: data.executionOrder,
endedAt: undefined,
workflowId,
blockId: data.blockId,
executionId: executionIdRef.current,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
isRunning: true,
...extractIterationFields(data),
})
}
const onBlockCompleted = (data: BlockCompletedData) => {
if (isStaleExecution()) return
updateActiveBlocks(data.blockId, false)
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'success')
markOutgoingEdges(data.blockId, data.output as Record<string, any> | undefined)
executedBlockIds.add(data.blockId)
accumulatedBlockStates.set(data.blockId, {
output: data.output,
executed: true,
executionTime: data.durationMs,
})
if (isContainerBlockType(data.blockType)) {
const originalId = stripCloneSuffixes(data.blockId)
if (originalId !== data.blockId) {
executedBlockIds.add(originalId)
if (workflowId) setBlockRunStatus(workflowId, originalId, 'success')
}
}
if (isContainerBlockType(data.blockType) && !data.iterationContainerId) {
const output = data.output as Record<string, any> | undefined
const isEmptySubflow = Array.isArray(output?.results) && output.results.length === 0
if (!isEmptySubflow) {
if (includeStartConsoleEntry) {
updateConsoleEntry(data)
}
return
}
}
accumulatedBlockLogs.push(createBlockLogEntry(data, { success: true, output: data.output }))
if (consoleMode === 'update') {
updateConsoleEntry(data)
} else {
addConsoleEntry(data, data.output as NormalizedBlockOutput)
}
if (onBlockCompleteCallback) {
onBlockCompleteCallback(data.blockId, data.output).catch((error) => {
logger.error('Error in onBlockComplete callback:', { blockId: data.blockId, error })
})
}
}
const onBlockError = (data: BlockErrorData) => {
if (isStaleExecution()) return
updateActiveBlocks(data.blockId, false)
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'error')
markOutgoingEdges(data.blockId, { error: data.error })
executedBlockIds.add(data.blockId)
accumulatedBlockStates.set(data.blockId, {
output: { error: data.error },
executed: true,
executionTime: data.durationMs || 0,
})
if (isContainerBlockType(data.blockType)) {
const originalId = stripCloneSuffixes(data.blockId)
if (originalId !== data.blockId) {
executedBlockIds.add(originalId)
if (workflowId) setBlockRunStatus(workflowId, originalId, 'error')
}
}
accumulatedBlockLogs.push(
createBlockLogEntry(data, { success: false, output: {}, error: data.error })
)
if (consoleMode === 'update') {
updateConsoleErrorEntry(data)
} else {
addConsoleErrorEntry(data)
}
}
const onBlockChildWorkflowStarted = (data: {
blockId: string
childWorkflowInstanceId: string
iterationCurrent?: number
iterationContainerId?: string
executionOrder?: number
}) => {
if (isStaleExecution()) return
updateConsole(
data.blockId,
{
childWorkflowInstanceId: data.childWorkflowInstanceId,
...(data.iterationCurrent !== undefined && { iterationCurrent: data.iterationCurrent }),
...(data.iterationContainerId !== undefined && {
iterationContainerId: data.iterationContainerId,
}),
...(data.executionOrder !== undefined && { executionOrder: data.executionOrder }),
},
executionIdRef.current
)
}
return { onBlockStarted, onBlockCompleted, onBlockError, onBlockChildWorkflowStarted }
}
export interface WorkflowExecutionOptions {
workflowId?: string
workflowInput?: any
@@ -436,7 +115,7 @@ export async function executeWorkflowWithFullLogging(
}
const executionId = options.executionId || uuidv4()
const { addConsole, updateConsole } = useTerminalConsoleStore.getState()
const { addConsole } = useTerminalConsoleStore.getState()
const { setActiveBlocks, setBlockRunStatus, setEdgeRunStatus, setCurrentExecutionId } =
useExecutionStore.getState()
const wfId = targetWorkflowId
@@ -444,24 +123,6 @@ export async function executeWorkflowWithFullLogging(
const activeBlocksSet = new Set<string>()
const activeBlockRefCounts = new Map<string, number>()
const executionIdRef = { current: executionId }
const blockHandlers = createBlockEventHandlers(
{
workflowId: wfId,
executionIdRef,
workflowEdges,
activeBlocksSet,
activeBlockRefCounts,
accumulatedBlockLogs: [],
accumulatedBlockStates: new Map(),
executedBlockIds: new Set(),
consoleMode: 'update',
includeStartConsoleEntry: true,
onBlockCompleteCallback: options.onBlockComplete,
},
{ addConsole, updateConsole, setActiveBlocks, setBlockRunStatus, setEdgeRunStatus }
)
const payload: any = {
input: options.workflowInput,
@@ -527,25 +188,125 @@ export async function executeWorkflowWithFullLogging(
switch (event.type) {
case 'execution:started': {
setCurrentExecutionId(wfId, event.executionId)
executionIdRef.current = event.executionId || executionId
break
}
case 'block:started': {
updateActiveBlockRefCount(
activeBlockRefCounts,
activeBlocksSet,
event.data.blockId,
true
)
setActiveBlocks(wfId, new Set(activeBlocksSet))
break
}
case 'block:started':
blockHandlers.onBlockStarted(event.data)
break
case 'block:completed': {
updateActiveBlockRefCount(
activeBlockRefCounts,
activeBlocksSet,
event.data.blockId,
false
)
setActiveBlocks(wfId, new Set(activeBlocksSet))
case 'block:completed':
blockHandlers.onBlockCompleted(event.data)
break
setBlockRunStatus(wfId, event.data.blockId, 'success')
markOutgoingEdgesFromOutput(
event.data.blockId,
event.data.output,
workflowEdges,
wfId,
setEdgeRunStatus
)
case 'block:error':
blockHandlers.onBlockError(event.data)
break
addConsole({
input: event.data.input || {},
output: event.data.output,
success: true,
durationMs: event.data.durationMs,
startedAt: new Date(Date.now() - event.data.durationMs).toISOString(),
executionOrder: event.data.executionOrder,
endedAt: new Date().toISOString(),
workflowId: targetWorkflowId,
blockId: event.data.blockId,
executionId,
blockName: event.data.blockName,
blockType: event.data.blockType,
iterationCurrent: event.data.iterationCurrent,
iterationTotal: event.data.iterationTotal,
iterationType: event.data.iterationType,
iterationContainerId: event.data.iterationContainerId,
childWorkflowBlockId: event.data.childWorkflowBlockId,
childWorkflowName: event.data.childWorkflowName,
childWorkflowInstanceId: event.data.childWorkflowInstanceId,
})
case 'block:childWorkflowStarted':
blockHandlers.onBlockChildWorkflowStarted(event.data)
if (options.onBlockComplete) {
options.onBlockComplete(event.data.blockId, event.data.output).catch(() => {})
}
break
}
case 'block:error': {
updateActiveBlockRefCount(
activeBlockRefCounts,
activeBlocksSet,
event.data.blockId,
false
)
setActiveBlocks(wfId, new Set(activeBlocksSet))
setBlockRunStatus(wfId, event.data.blockId, 'error')
markOutgoingEdgesFromOutput(
event.data.blockId,
{ error: event.data.error },
workflowEdges,
wfId,
setEdgeRunStatus
)
addConsole({
input: event.data.input || {},
output: {},
success: false,
error: event.data.error,
durationMs: event.data.durationMs,
startedAt: new Date(Date.now() - event.data.durationMs).toISOString(),
executionOrder: event.data.executionOrder,
endedAt: new Date().toISOString(),
workflowId: targetWorkflowId,
blockId: event.data.blockId,
executionId,
blockName: event.data.blockName,
blockType: event.data.blockType,
iterationCurrent: event.data.iterationCurrent,
iterationTotal: event.data.iterationTotal,
iterationType: event.data.iterationType,
iterationContainerId: event.data.iterationContainerId,
childWorkflowBlockId: event.data.childWorkflowBlockId,
childWorkflowName: event.data.childWorkflowName,
childWorkflowInstanceId: event.data.childWorkflowInstanceId,
})
break
}
case 'block:childWorkflowStarted': {
const { updateConsole } = useTerminalConsoleStore.getState()
updateConsole(
event.data.blockId,
{
childWorkflowInstanceId: event.data.childWorkflowInstanceId,
...(event.data.iterationCurrent !== undefined && {
iterationCurrent: event.data.iterationCurrent,
}),
...(event.data.iterationContainerId !== undefined && {
iterationContainerId: event.data.iterationContainerId,
}),
},
executionId
)
break
}
case 'execution:completed':
setCurrentExecutionId(wfId, null)

View File

@@ -1,121 +0,0 @@
import { Folder } from 'lucide-react'
import Link from 'next/link'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import type { FolderTreeNode } from '@/stores/folders/types'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
interface CollapsedSidebarMenuProps {
icon: React.ReactNode
hover: ReturnType<typeof useHoverMenu>
onClick?: () => void
ariaLabel?: string
children: React.ReactNode
className?: string
}
export function CollapsedSidebarMenu({
icon,
hover,
onClick,
ariaLabel,
children,
className,
}: CollapsedSidebarMenuProps) {
return (
<div className={cn('flex flex-col px-[8px]', className)}>
<DropdownMenu
open={hover.isOpen}
onOpenChange={(open) => {
if (open) hover.open()
else hover.close()
}}
modal={false}
>
<div {...hover.triggerProps}>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label={ariaLabel}
className='mx-[2px] flex h-[30px] items-center rounded-[8px] px-[8px] hover:bg-[var(--surface-active)]'
onClick={onClick}
>
{icon}
</button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent side='right' align='start' sideOffset={8} {...hover.contentProps}>
{children}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export function CollapsedFolderItems({
nodes,
workflowsByFolder,
workspaceId,
}: {
nodes: FolderTreeNode[]
workflowsByFolder: Record<string, WorkflowMetadata[]>
workspaceId: string
}) {
return (
<>
{nodes.map((folder) => {
const folderWorkflows = workflowsByFolder[folder.id] || []
const hasChildren = folder.children.length > 0 || folderWorkflows.length > 0
if (!hasChildren) {
return (
<DropdownMenuItem key={folder.id} disabled>
<Folder className='h-[14px] w-[14px]' />
<span className='truncate'>{folder.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuSub key={folder.id}>
<DropdownMenuSubTrigger>
<Folder className='h-[14px] w-[14px]' />
<span className='truncate'>{folder.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<CollapsedFolderItems
nodes={folder.children}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
/>
{folderWorkflows.map((workflow) => (
<DropdownMenuItem key={workflow.id} asChild>
<Link href={`/workspace/${workspaceId}/w/${workflow.id}`}>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: workflow.color,
borderColor: `${workflow.color}60`,
backgroundClip: 'padding-box',
}}
/>
<span className='truncate'>{workflow.name}</span>
</Link>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</>
)
}

View File

@@ -1,7 +1,3 @@
export {
CollapsedFolderItems,
CollapsedSidebarMenu,
} from './collapsed-sidebar-menu/collapsed-sidebar-menu'
export { HelpModal } from './help-modal/help-modal'
export { NavItemContextMenu } from './nav-item-context-menu'
export { SearchModal } from './search-modal/search-modal'

View File

@@ -3,7 +3,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { Badge, Skeleton } from '@/components/emcn'
import { Badge } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { USAGE_PILL_COLORS, USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
import {

View File

@@ -13,10 +13,6 @@ import {
useSidebarDragContextValue,
useWorkflowSelection,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import {
compareByOrder,
groupWorkflowsByFolder,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
import { useFolders } from '@/hooks/queries/folders'
import { useFolderStore } from '@/stores/folders/store'
import type { FolderTreeNode } from '@/stores/folders/types'
@@ -26,6 +22,17 @@ const TREE_SPACING = {
INDENT_PER_LEVEL: 20,
} as const
function compareByOrder<T extends { sortOrder: number; createdAt?: Date; id: string }>(
a: T,
b: T
): number {
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
const timeA = a.createdAt?.getTime() ?? 0
const timeB = b.createdAt?.getTime() ?? 0
if (timeA !== timeB) return timeA - timeB
return a.id.localeCompare(b.id)
}
interface WorkflowListProps {
workspaceId: string
workflowId: string | undefined
@@ -122,10 +129,21 @@ export const WorkflowList = memo(function WorkflowList({
return activeWorkflow?.folderId || null
}, [workflowId, regularWorkflows, isLoading, foldersLoading])
const workflowsByFolder = useMemo(
() => groupWorkflowsByFolder(regularWorkflows),
[regularWorkflows]
)
const workflowsByFolder = useMemo(() => {
const grouped = regularWorkflows.reduce(
(acc, workflow) => {
const folderId = workflow.folderId || 'root'
if (!acc[folderId]) acc[folderId] = []
acc[folderId].push(workflow)
return acc
},
{} as Record<string, WorkflowMetadata[]>
)
for (const folderId of Object.keys(grouped)) {
grouped[folderId].sort(compareByOrder)
}
return grouped
}, [regularWorkflows])
const orderedWorkflowIds = useMemo(() => {
const ids: string[] = []

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { Loader2, RotateCw, X } from 'lucide-react'
import { Badge, Button, Skeleton, Tooltip } from '@/components/emcn'
import { Badge, Button, Tooltip } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import { useSession } from '@/lib/auth/auth-client'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'

View File

@@ -4,7 +4,6 @@ export { type DropIndicator, useDragDrop } from './use-drag-drop'
export { useFolderExpand } from './use-folder-expand'
export { useFolderOperations } from './use-folder-operations'
export { useFolderSelection } from './use-folder-selection'
export { useHoverMenu } from './use-hover-menu'
export { useItemDrag } from './use-item-drag'
export { useItemRename } from './use-item-rename'
export {

View File

@@ -1,62 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
const CLOSE_DELAY_MS = 150
const preventAutoFocus = (e: Event) => e.preventDefault()
/**
* Manages hover-triggered dropdown menu state.
* Provides handlers for trigger and content mouse events with a delay
* to prevent flickering when moving between trigger and content.
*/
export function useHoverMenu() {
const [isOpen, setIsOpen] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const cancelClose = useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
}, [])
useEffect(() => {
return () => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current)
}
}
}, [])
const scheduleClose = useCallback(() => {
cancelClose()
closeTimerRef.current = setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS)
}, [cancelClose])
const open = useCallback(() => {
cancelClose()
setIsOpen(true)
}, [cancelClose])
const close = useCallback(() => {
cancelClose()
setIsOpen(false)
}, [cancelClose])
const triggerProps = useMemo(
() => ({ onMouseEnter: open, onMouseLeave: scheduleClose }) as const,
[open, scheduleClose]
)
const contentProps = useMemo(
() =>
({
onMouseEnter: cancelClose,
onMouseLeave: scheduleClose,
onCloseAutoFocus: preventAutoFocus,
}) as const,
[cancelClose, scheduleClose]
)
return { isOpen, open, close, triggerProps, contentProps }
}

View File

@@ -1,6 +1,6 @@
'use client'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
@@ -38,8 +38,6 @@ import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/provide
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
CollapsedFolderItems,
CollapsedSidebarMenu,
HelpModal,
NavItemContextMenu,
SearchModal,
@@ -52,20 +50,17 @@ import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/
import {
useContextMenu,
useFolderOperations,
useHoverMenu,
useSidebarResize,
useTaskSelection,
useWorkflowOperations,
useWorkspaceManagement,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { groupWorkflowsByFolder } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
import {
useDuplicateWorkspace,
useExportWorkspace,
useImportWorkflow,
useImportWorkspace,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useFolders } from '@/hooks/queries/folders'
import { useDeleteTask, useDeleteTasks, useRenameTask, useTasks } from '@/hooks/queries/tasks'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -79,7 +74,7 @@ const logger = createLogger('Sidebar')
function SidebarItemSkeleton() {
return (
<div className='sidebar-collapse-hide mx-[2px] flex h-[30px] items-center px-[8px]'>
<div className='mx-[2px] flex h-[30px] items-center px-[8px]'>
<Skeleton className='h-[24px] w-full rounded-[4px]' />
</div>
)
@@ -270,12 +265,6 @@ export const Sidebar = memo(function Sidebar() {
const [showCollapsedContent, setShowCollapsedContent] = useState(isCollapsed)
useLayoutEffect(() => {
if (!isCollapsed) {
document.documentElement.removeAttribute('data-sidebar-collapsed')
}
}, [isCollapsed])
useEffect(() => {
if (isCollapsed) {
const timer = setTimeout(() => setShowCollapsedContent(true), 200)
@@ -367,20 +356,6 @@ export const Sidebar = memo(function Sidebar() {
workspaceId,
})
useFolders(workspaceId)
const folders = useFolderStore((s) => s.folders)
const getFolderTree = useFolderStore((s) => s.getFolderTree)
const folderTree = useMemo(
() => (isCollapsed && workspaceId ? getFolderTree(workspaceId) : []),
[isCollapsed, workspaceId, folders, getFolderTree]
)
const workflowsByFolder = useMemo(
() => (isCollapsed ? groupWorkflowsByFolder(regularWorkflows) : {}),
[isCollapsed, regularWorkflows]
)
const [activeNavItemHref, setActiveNavItemHref] = useState<string | null>(null)
const {
isOpen: isNavContextMenuOpen,
@@ -657,8 +632,6 @@ export const Sidebar = memo(function Sidebar() {
const [visibleTaskCount, setVisibleTaskCount] = useState(5)
const [renamingTaskId, setRenamingTaskId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const tasksHover = useHoverMenu()
const workflowsHover = useHoverMenu()
const renameInputRef = useRef<HTMLInputElement>(null)
const renameCanceledRef = useRef(false)
@@ -987,7 +960,7 @@ export const Sidebar = memo(function Sidebar() {
type='button'
onClick={toggleCollapsed}
className={cn(
'sidebar-collapse-btn ml-auto flex h-[30px] items-center justify-center overflow-hidden rounded-[8px] transition-all duration-200 hover:bg-[var(--surface-active)]',
'ml-auto flex h-[30px] items-center justify-center overflow-hidden rounded-[8px] transition-all duration-200 hover:bg-[var(--surface-active)]',
isCollapsed ? 'w-0 opacity-0' : 'w-[30px] opacity-100'
)}
aria-label='Collapse sidebar'
@@ -1050,11 +1023,13 @@ export const Sidebar = memo(function Sidebar() {
{/* Workspace */}
<div className='mt-[14px] flex flex-shrink-0 flex-col pb-[8px]'>
{!isCollapsed && (
<div className='sidebar-collapse-remove px-[16px] pb-[6px]'>
<div className='font-base text-[var(--text-icon)] text-small'>Workspace</div>
<div className='px-[16px] pb-[6px]'>
<div
className={`font-base text-[var(--text-icon)] text-small${isCollapsed ? ' opacity-0' : ''}`}
>
Workspace
</div>
)}
</div>
<div className='flex flex-col gap-[2px] px-[8px]'>
{workspaceNavItems.map((item) => (
<SidebarNavItem
@@ -1078,170 +1053,99 @@ export const Sidebar = memo(function Sidebar() {
>
{/* Tasks */}
<div className='flex flex-shrink-0 flex-col'>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
}
hover={tasksHover}
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
ariaLabel='Tasks'
>
{tasksLoading ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : (
tasks.map((task) => (
<DropdownMenuItem key={task.id} asChild>
<Link href={task.href}>
<Blimp className='h-[16px] w-[16px]' />
<span>{task.name}</span>
</Link>
</DropdownMenuItem>
))
)}
</CollapsedSidebarMenu>
) : (
<div className='sidebar-collapse-remove'>
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
<div className='flex items-center justify-between'>
<div className='font-base text-[var(--text-icon)] text-small'>
All tasks
</div>
<div className='flex items-center justify-center gap-[8px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>New task</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
</div>
<div className='mt-[6px] flex flex-col gap-[2px] px-[8px]'>
{tasksLoading ? (
<SidebarItemSkeleton />
) : (
<>
{tasks.slice(0, visibleTaskCount).map((task) => {
const isCurrentRoute = task.id !== 'new' && pathname === task.href
const isRenaming = renamingTaskId === task.id
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
if (isRenaming) {
return (
<div
key={task.id}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] bg-[var(--surface-active)] px-[8px] text-[14px]'
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleSaveTaskRename}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
)
}
return (
<SidebarTaskItem
key={task.id}
task={task}
isCurrentRoute={isCurrentRoute}
isSelected={isSelected}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
showCollapsedContent={showCollapsedContent}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
)
})}
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={() => setVisibleTaskCount((prev) => prev + 5)}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] text-[var(--text-icon)] hover:bg-[var(--surface-active)]'
>
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
<span className='font-base'>See more</span>
</button>
)}
</>
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
<div className='flex items-center justify-between'>
<div
className={cn(
'font-base text-[var(--text-icon)] text-small',
isCollapsed && 'opacity-0'
)}
>
All tasks
</div>
{!isCollapsed && (
<div className='flex items-center justify-center gap-[8px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>New task</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
)}
</div>
<div className='mt-[6px] flex flex-col gap-[2px] px-[8px]'>
{tasksLoading ? (
<SidebarItemSkeleton />
) : (
<>
{tasks.slice(0, visibleTaskCount).map((task) => {
const isCurrentRoute = task.id !== 'new' && pathname === task.href
const isRenaming = renamingTaskId === task.id
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
if (!isCollapsed && isRenaming) {
return (
<div
key={task.id}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] bg-[var(--surface-active)] px-[8px] text-[14px]'
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleSaveTaskRename}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
)
}
return (
<SidebarTaskItem
key={task.id}
task={task}
isCurrentRoute={isCurrentRoute}
isSelected={isSelected}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
showCollapsedContent={showCollapsedContent}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
)
})}
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={() => setVisibleTaskCount((prev) => prev + 5)}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] text-[var(--text-icon)] hover:bg-[var(--surface-active)]'
>
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
<span className='font-base'>See more</span>
</button>
)}
</>
)}
</div>
</div>
{/* Workflows */}
{isCollapsed ? (
<CollapsedSidebarMenu
icon={
<div
className='h-[16px] w-[16px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: 'var(--text-icon)',
borderColor: 'color-mix(in srgb, var(--text-icon) 60%, transparent)',
backgroundClip: 'padding-box',
}}
/>
}
hover={workflowsHover}
onClick={handleCreateWorkflow}
ariaLabel='Workflows'
className='mt-[14px]'
>
{workflowsLoading && regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>No workflows yet</DropdownMenuItem>
) : (
<>
<CollapsedFolderItems
nodes={folderTree}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
/>
{(workflowsByFolder.root || []).map((workflow) => (
<DropdownMenuItem key={workflow.id} asChild>
<Link href={`/workspace/${workspaceId}/w/${workflow.id}`}>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: workflow.color,
borderColor: `${workflow.color}60`,
backgroundClip: 'padding-box',
}}
/>
<span className='truncate'>{workflow.name}</span>
</Link>
</DropdownMenuItem>
))}
</>
)}
</CollapsedSidebarMenu>
) : (
<div className='sidebar-collapse-remove workflows-section relative mt-[14px] flex flex-col'>
{!isCollapsed && (
<div className='workflows-section relative mt-[14px] flex flex-col'>
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
<div className='flex items-center justify-between'>
<div className='font-base text-[var(--text-icon)] text-small'>

View File

@@ -1,30 +0,0 @@
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
export function compareByOrder<T extends { sortOrder: number; createdAt?: Date; id: string }>(
a: T,
b: T
): number {
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
const timeA = a.createdAt?.getTime() ?? 0
const timeB = b.createdAt?.getTime() ?? 0
if (timeA !== timeB) return timeA - timeB
return a.id.localeCompare(b.id)
}
export function groupWorkflowsByFolder(
workflows: WorkflowMetadata[]
): Record<string, WorkflowMetadata[]> {
const grouped = workflows.reduce(
(acc, workflow) => {
const folderId = workflow.folderId || 'root'
if (!acc[folderId]) acc[folderId] = []
acc[folderId].push(workflow)
return acc
},
{} as Record<string, WorkflowMetadata[]>
)
for (const key of Object.keys(grouped)) {
grouped[key].sort(compareByOrder)
}
return grouped
}

View File

@@ -4,12 +4,14 @@ import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { useSession } from '@/lib/auth/auth-client'
import { useReferralAttribution } from '@/hooks/use-referral-attribution'
const logger = createLogger('WorkspacePage')
export default function WorkspacePage() {
const router = useRouter()
const { data: session, isPending } = useSession()
useReferralAttribution()
useEffect(() => {
const redirectToFirstWorkspace = async () => {

View File

@@ -0,0 +1,54 @@
import * as React from 'react'
import { cn } from '@/lib/core/utils/cn'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('font-semibold text-2xl leading-none tracking-tight', className)}
{...props}
/>
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />
)
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,11 @@
'use client'
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,184 @@
'use client'
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className='ml-auto' />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<Check className='h-4 w-4' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<Circle className='h-2 w-2 fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 font-semibold text-sm', inset && 'pl-8', className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -1,5 +1,7 @@
export { Alert, AlertDescription, AlertTitle } from './alert'
export { Button, buttonVariants } from './button'
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './card'
export { Collapsible, CollapsibleContent, CollapsibleTrigger } from './collapsible'
export {
Dialog,
DialogClose,
@@ -12,10 +14,28 @@ export {
DialogTitle,
DialogTrigger,
} from './dialog'
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from './dropdown-menu'
export { Input } from './input'
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from './input-otp'
export { Label } from './label'
export { Progress } from './progress'
export { ScrollArea, ScrollBar } from './scroll-area'
export { SearchHighlight } from './search-highlight'
export {
Select,
@@ -29,3 +49,7 @@ export {
SelectTrigger,
SelectValue,
} from './select'
export { Separator } from './separator'
export { Skeleton } from './skeleton'
export { TagInput } from './tag-input'
export { ToolCallCompletion, ToolCallExecution } from './tool-call'

View File

@@ -0,0 +1,52 @@
'use client'
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/core/utils/cn'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
hideScrollbar?: boolean
}
>(({ className, children, hideScrollbar = false, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className='h-full w-full rounded-[inherit]'>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar hidden={hideScrollbar} />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> & {
hidden?: boolean
}
>(({ className, orientation = 'vertical', hidden = false, ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
hidden && 'pointer-events-none w-0 border-0 p-0 opacity-0',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
className={cn('relative flex-1 rounded-full bg-border', hidden && 'hidden')}
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,25 @@
'use client'
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/core/utils/cn'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,7 @@
import { cn } from '@/lib/core/utils/cn'
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />
}
export { Skeleton }

View File

@@ -0,0 +1,112 @@
'use client'
import { type KeyboardEvent, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/core/utils/cn'
interface TagInputProps {
value: string[]
onChange: (tags: string[]) => void
placeholder?: string
maxTags?: number
disabled?: boolean
className?: string
}
export function TagInput({
value = [],
onChange,
placeholder = 'Type and press Enter',
maxTags = 10,
disabled = false,
className,
}: TagInputProps) {
const [inputValue, setInputValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const addTag = (tag: string) => {
const trimmedTag = tag.trim()
if (trimmedTag && !value.includes(trimmedTag) && value.length < maxTags) {
onChange([...value, trimmedTag])
setInputValue('')
}
}
const removeTag = (tagToRemove: string) => {
onChange(value.filter((tag) => tag !== tagToRemove))
}
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && inputValue.trim()) {
e.preventDefault()
addTag(inputValue)
} else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
removeTag(value[value.length - 1])
}
}
const handleBlur = () => {
if (inputValue.trim()) {
addTag(inputValue)
}
}
return (
<div
className={cn(
'scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[4px] focus-within:outline-none',
disabled && 'cursor-not-allowed opacity-50',
className
)}
onClick={() => !disabled && inputRef.current?.focus()}
>
{value.map((tag) => (
<Tag key={tag} value={tag} onRemove={() => removeTag(tag)} disabled={disabled} />
))}
{!disabled && value.length < maxTags && (
<Input
ref={inputRef}
type='text'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
placeholder={value.length === 0 ? placeholder : ''}
disabled={disabled}
className={cn(
'h-6 min-w-[180px] flex-1 border-none bg-transparent p-0 font-medium font-sans text-sm placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0',
value.length > 0 ? 'pl-[4px]' : 'pl-[4px]'
)}
/>
)}
</div>
)
}
interface TagProps {
value: string
onRemove: () => void
disabled?: boolean
}
function Tag({ value, onRemove, disabled }: TagProps) {
return (
<div className='flex w-auto items-center gap-[4px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[6px] py-[2px] text-[12px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'>
<span className='max-w-[200px] truncate'>{value}</span>
{!disabled && (
<button
type='button'
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className='flex-shrink-0 text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)] focus:outline-none'
aria-label={`Remove ${value}`}
>
<X className='h-[12px] w-[12px] translate-y-[0.2px]' />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,469 @@
'use client'
import { useState } from 'react'
import { CheckCircle, ChevronDown, ChevronRight, Loader2, Settings, XCircle } from 'lucide-react'
import { Badge } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
interface ToolCallState {
id: string
name: string
displayName?: string
parameters?: Record<string, unknown>
state:
| 'detecting'
| 'pending'
| 'executing'
| 'completed'
| 'error'
| 'rejected'
| 'applied'
| 'ready_for_review'
| 'aborted'
| 'skipped'
| 'background'
startTime?: number
endTime?: number
duration?: number
result?: unknown
error?: string
progress?: string
}
interface ToolCallGroup {
id: string
toolCalls: ToolCallState[]
status: 'pending' | 'in_progress' | 'completed' | 'error'
startTime?: number
endTime?: number
summary?: string
}
interface ToolCallProps {
toolCall: ToolCallState
isCompact?: boolean
}
interface ToolCallGroupProps {
group: ToolCallGroup
isCompact?: boolean
}
interface ToolCallIndicatorProps {
type: 'status' | 'thinking' | 'execution'
content: string
toolNames?: string[]
}
// Detection State Component
export function ToolCallDetection({ content }: { content: string }) {
return (
<div className='flex min-w-0 items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm dark:border-blue-800 dark:bg-blue-950'>
<Loader2 className='h-4 w-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400' />
<span className='min-w-0 truncate text-blue-800 dark:text-blue-200'>{content}</span>
</div>
)
}
// Execution State Component
export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps) {
const [isExpanded, setIsExpanded] = useState(!isCompact)
return (
<div className='min-w-0 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950'>
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleTrigger asChild>
<Button
variant='ghost'
className='w-full min-w-0 justify-between px-3 py-4 hover:bg-amber-100 dark:hover:bg-amber-900'
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
<Settings className='h-4 w-4 shrink-0 animate-pulse text-amber-600 dark:text-amber-400' />
<span className='min-w-0 truncate font-mono text-amber-800 text-xs dark:text-amber-200'>
{toolCall.displayName || toolCall.name}
</span>
{toolCall.progress && (
<Badge
variant='outline'
className='shrink-0 text-amber-700 text-xs dark:text-amber-300'
>
{toolCall.progress}
</Badge>
)}
</div>
{isExpanded ? (
<ChevronDown className='h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400' />
) : (
<ChevronRight className='h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400' />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className='min-w-0 max-w-full px-3 pb-3'>
<div className='min-w-0 max-w-full space-y-2'>
<div className='flex items-center gap-2 text-amber-700 text-xs dark:text-amber-300'>
<Loader2 className='h-3 w-3 shrink-0 animate-spin' />
<span>Executing...</span>
</div>
{toolCall.parameters &&
Object.keys(toolCall.parameters).length > 0 &&
(toolCall.name === 'make_api_request' ||
toolCall.name === 'set_environment_variables' ||
toolCall.name === 'set_global_workflow_variables') && (
<div className='min-w-0 max-w-full rounded border border-amber-200 bg-amber-50 p-2 dark:border-amber-800 dark:bg-amber-950'>
{toolCall.name === 'make_api_request' ? (
<div className='w-full overflow-hidden rounded border border-muted bg-card'>
<div className='grid grid-cols-2 gap-0 border-muted/60 border-b bg-muted/40 px-2 py-1.5'>
<div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'>
Method
</div>
<div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'>
Endpoint
</div>
</div>
<div className='grid grid-cols-[auto_1fr] items-center gap-2 px-2 py-2'>
<div>
<span className='inline-flex rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground text-xs'>
{String((toolCall.parameters as any).method || '').toUpperCase() ||
'GET'}
</span>
</div>
<div className='min-w-0'>
<span
className='block overflow-x-auto whitespace-nowrap font-mono text-foreground text-xs'
title={String((toolCall.parameters as any).url || '')}
>
{String((toolCall.parameters as any).url || '') || 'URL not provided'}
</span>
</div>
</div>
</div>
) : null}
{toolCall.name === 'set_environment_variables'
? (() => {
const variables =
(toolCall.parameters as any).variables &&
typeof (toolCall.parameters as any).variables === 'object'
? (toolCall.parameters as any).variables
: {}
const entries = Object.entries(variables)
return (
<div className='w-full overflow-hidden rounded border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950'>
<div className='grid grid-cols-2 gap-0 border-amber-200/60 border-b px-2 py-1.5 dark:border-amber-800/60'>
<div className='font-medium text-[10px] text-amber-700 uppercase tracking-wide dark:text-amber-300'>
Name
</div>
<div className='font-medium text-[10px] text-amber-700 uppercase tracking-wide dark:text-amber-300'>
Value
</div>
</div>
{entries.length === 0 ? (
<div className='px-2 py-2 text-muted-foreground text-xs'>
No variables provided
</div>
) : (
<div className='divide-y divide-amber-200 dark:divide-amber-800'>
{entries.map(([k, v]) => (
<div
key={k}
className='grid grid-cols-[auto_1fr] items-center gap-2 px-2 py-1.5'
>
<div className='truncate font-medium text-amber-800 text-xs dark:text-amber-200'>
{k}
</div>
<div className='min-w-0'>
<span className='block overflow-x-auto whitespace-nowrap font-mono text-amber-700 text-xs dark:text-amber-300'>
{String(v)}
</span>
</div>
</div>
))}
</div>
)}
</div>
)
})()
: null}
{toolCall.name === 'set_global_workflow_variables'
? (() => {
const ops = Array.isArray((toolCall.parameters as any).operations)
? ((toolCall.parameters as any).operations as any[])
: []
return (
<div className='w-full overflow-hidden rounded border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950'>
<div className='grid grid-cols-3 gap-0 border-amber-200/60 border-b px-2 py-1.5 dark:border-amber-800/60'>
<div className='font-medium text-[10px] text-amber-700 uppercase tracking-wide dark:text-amber-300'>
Name
</div>
<div className='font-medium text-[10px] text-amber-700 uppercase tracking-wide dark:text-amber-300'>
Type
</div>
<div className='font-medium text-[10px] text-amber-700 uppercase tracking-wide dark:text-amber-300'>
Value
</div>
</div>
{ops.length === 0 ? (
<div className='px-2 py-2 text-muted-foreground text-xs'>
No operations provided
</div>
) : (
<div className='divide-y divide-amber-200 dark:divide-amber-800'>
{ops.map((op, idx) => (
<div
key={idx}
className='grid grid-cols-3 items-center gap-0 px-2 py-1.5'
>
<div className='min-w-0'>
<span className='truncate text-amber-800 text-xs dark:text-amber-200'>
{String(op.name || '')}
</span>
</div>
<div>
<span className='rounded border px-1 py-0.5 text-[10px] text-muted-foreground'>
{String(op.type || '')}
</span>
</div>
<div className='min-w-0'>
{op.value !== undefined ? (
<span className='block overflow-x-auto whitespace-nowrap font-mono text-amber-700 text-xs dark:text-amber-300'>
{String(op.value)}
</span>
) : (
<span className='text-muted-foreground text-xs'></span>
)}
</div>
</div>
))}
</div>
)}
</div>
)
})()
: null}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
)
}
export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProps) {
const [isExpanded, setIsExpanded] = useState(false)
const isSuccess = toolCall.state === 'completed'
const isError = toolCall.state === 'error'
const isAborted = toolCall.state === 'aborted'
return (
<div
className={cn(
'min-w-0 rounded-lg border',
isSuccess && 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950',
isError && 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950',
isAborted && 'border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950'
)}
>
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleTrigger asChild>
<Button
variant='ghost'
className={cn(
'w-full min-w-0 justify-between px-3 py-4',
isSuccess && 'hover:bg-green-100 dark:hover:bg-green-900',
isError && 'hover:bg-red-100 dark:hover:bg-red-900',
isAborted && 'hover:bg-orange-100 dark:hover:bg-orange-900'
)}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{isSuccess && (
<CheckCircle className='h-4 w-4 shrink-0 text-green-600 dark:text-green-400' />
)}
{isError && <XCircle className='h-4 w-4 shrink-0 text-red-600 dark:text-red-400' />}
{isAborted && (
<XCircle className='h-4 w-4 shrink-0 text-orange-600 dark:text-orange-400' />
)}
<span
className={cn(
'min-w-0 truncate font-mono text-xs',
isSuccess && 'text-green-800 dark:text-green-200',
isError && 'text-red-800 dark:text-red-200',
isAborted && 'text-orange-800 dark:text-orange-200'
)}
>
{toolCall.displayName || toolCall.name}
</span>
{toolCall.duration && (
<Badge
variant='outline'
className={cn(
'shrink-0 text-xs',
isSuccess && 'text-green-700 dark:text-green-300',
isError && 'text-red-700 dark:text-red-300',
isAborted && 'text-orange-700 dark:text-orange-300'
)}
style={{ fontSize: '0.625rem' }}
>
{toolCall.duration ? formatDuration(toolCall.duration, { precision: 1 }) : ''}
</Badge>
)}
</div>
<div className='flex shrink-0 items-center'>
{isExpanded ? (
<ChevronDown
className={cn(
'h-4 w-4',
isSuccess && 'text-green-600 dark:text-green-400',
isError && 'text-red-600 dark:text-red-400'
)}
/>
) : (
<ChevronRight
className={cn(
'h-4 w-4',
isSuccess && 'text-green-600 dark:text-green-400',
isError && 'text-red-600 dark:text-red-400'
)}
/>
)}
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className='min-w-0 max-w-full px-3 pb-3'>
<div className='min-w-0 max-w-full space-y-2'>
{toolCall.parameters &&
Object.keys(toolCall.parameters).length > 0 &&
(toolCall.name === 'make_api_request' ||
toolCall.name === 'set_environment_variables') && (
<div
className={cn(
'min-w-0 max-w-full rounded p-2',
isSuccess && 'bg-green-100 dark:bg-green-900',
isError && 'bg-red-100 dark:bg-red-900'
)}
>
<div
className={cn(
'mb-1 font-medium text-xs',
isSuccess && 'text-green-800 dark:text-green-200',
isError && 'text-red-800 dark:text-red-200'
)}
>
Parameters:
</div>
<div
className={cn(
'min-w-0 max-w-full break-all font-mono text-xs',
isSuccess && 'text-green-700 dark:text-green-300',
isError && 'text-red-700 dark:text-red-300'
)}
>
{JSON.stringify(toolCall.parameters, null, 2)}
</div>
</div>
)}
{toolCall.error && (
<div className='min-w-0 max-w-full rounded bg-red-100 p-2 dark:bg-red-900'>
<div className='mb-1 font-medium text-red-800 text-xs dark:text-red-200'>
Error:
</div>
<div className='min-w-0 max-w-full break-all font-mono text-red-700 text-xs dark:text-red-300'>
{toolCall.error}
</div>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
)
}
// Group Component for Multiple Tool Calls
export function ToolCallGroupComponent({ group, isCompact = false }: ToolCallGroupProps) {
const [isExpanded, setIsExpanded] = useState(true)
const completedCount = group.toolCalls.filter((t) => t.state === 'completed').length
const totalCount = group.toolCalls.length
const isAllCompleted = completedCount === totalCount
const hasErrors = group.toolCalls.some((t) => t.state === 'error')
return (
<div className='min-w-0 space-y-2'>
{group.summary && (
<div className='flex min-w-0 items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm dark:border-blue-800 dark:bg-blue-950'>
<Settings className='h-4 w-4 shrink-0 text-blue-600 dark:text-blue-400' />
<span className='min-w-0 truncate text-blue-800 dark:text-blue-200'>{group.summary}</span>
{!isAllCompleted && (
<Badge variant='outline' className='shrink-0 text-blue-700 text-xs dark:text-blue-300'>
{completedCount}/{totalCount}
</Badge>
)}
</div>
)}
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleTrigger asChild>
<Button
variant='ghost'
className='w-full min-w-0 justify-between px-3 py-3 text-sm hover:bg-muted'
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
<span className='min-w-0 truncate text-muted-foreground'>
{isAllCompleted ? 'Completed' : 'In Progress'} ({completedCount}/{totalCount})
</span>
{hasErrors && (
<Badge variant='red' className='shrink-0 text-xs'>
Errors
</Badge>
)}
</div>
{isExpanded ? (
<ChevronDown className='h-4 w-4 shrink-0 text-muted-foreground' />
) : (
<ChevronRight className='h-4 w-4 shrink-0 text-muted-foreground' />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className='min-w-0 max-w-full space-y-2'>
{group.toolCalls.map((toolCall) => (
<div key={toolCall.id} className='min-w-0 max-w-full'>
{toolCall.state === 'executing' && (
<ToolCallExecution toolCall={toolCall} isCompact={isCompact} />
)}
{(toolCall.state === 'completed' || toolCall.state === 'error') && (
<ToolCallCompletion toolCall={toolCall} isCompact={isCompact} />
)}
</div>
))}
</CollapsibleContent>
</Collapsible>
</div>
)
}
// Status Indicator Component
export function ToolCallIndicator({ type, content, toolNames }: ToolCallIndicatorProps) {
if (type === 'status' && toolNames) {
return (
<div className='flex min-w-0 items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm dark:border-blue-800 dark:bg-blue-950'>
<Loader2 className='h-4 w-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400' />
<span className='min-w-0 truncate text-blue-800 dark:text-blue-200'>
🔄 {toolNames.join(' • ')}
</span>
</div>
)
}
return (
<div className='flex min-w-0 items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm dark:border-blue-800 dark:bg-blue-950'>
<Loader2 className='h-4 w-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400' />
<span className='min-w-0 truncate text-blue-800 dark:text-blue-200'>{content}</span>
</div>
)
}

View File

@@ -20,10 +20,9 @@ import {
ModalTabsContent,
ModalTabsList,
ModalTabsTrigger,
Skeleton,
Switch,
} from '@/components/emcn'
import { Input as BaseInput } from '@/components/ui'
import { Input as BaseInput, Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionStatus } from '@/lib/billing/client'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'

View File

@@ -3,7 +3,8 @@
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check, ChevronDown, Clipboard, Eye, EyeOff } from 'lucide-react'
import { Button, Combobox, Input, Skeleton, Switch, Textarea } from '@/components/emcn'
import { Button, Combobox, Input, Switch, Textarea } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionStatus } from '@/lib/billing/client/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'

View File

@@ -439,10 +439,7 @@ export function isValidEnvVarName(name: string): boolean {
return PATTERNS.ENV_VAR_NAME.test(name)
}
export function sanitizeFileName(fileName: string | null | undefined): string {
if (!fileName || typeof fileName !== 'string') {
return 'untitled'
}
export function sanitizeFileName(fileName: string): string {
return fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
}

View File

@@ -297,6 +297,49 @@ export function useUpgradeSubscription() {
})
}
/**
* Redeem referral/promo code mutation
*/
interface RedeemReferralCodeParams {
code: string
}
interface RedeemReferralCodeResponse {
redeemed: boolean
bonusAmount?: number
error?: string
}
export function useRedeemReferralCode() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ code }: RedeemReferralCodeParams): Promise<RedeemReferralCodeResponse> => {
const response = await fetch('/api/referral-code/redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to redeem code')
}
if (!data.redeemed) {
throw new Error(data.error || 'Code could not be redeemed')
}
return data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() })
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() })
},
})
}
/**
* Purchase credits mutation
*/

View File

@@ -0,0 +1,66 @@
'use client'
import { useEffect, useRef } from 'react'
import { createLogger } from '@sim/logger'
import { useMutation } from '@tanstack/react-query'
const logger = createLogger('ReferralAttribution')
const COOKIE_NAME = 'sim_utm'
const TERMINAL_REASONS = new Set([
'invalid_cookie',
'no_utm_cookie',
'no_matching_campaign',
'already_attributed',
])
async function postAttribution(): Promise<{
attributed?: boolean
bonusAmount?: number
reason?: string
error?: string
}> {
const response = await fetch('/api/attribution', { method: 'POST' })
if (!response.ok) {
throw new Error(`Attribution request failed: ${response.status}`)
}
return response.json()
}
/**
* Fires a one-shot `POST /api/attribution` when a `sim_utm` cookie is present.
* Retries on transient failures; stops on terminal outcomes.
*/
export function useReferralAttribution() {
const calledRef = useRef(false)
const { mutate } = useMutation({
mutationFn: postAttribution,
retry: (failureCount, error) => {
if (failureCount >= 3) return false
logger.warn('Referral attribution failed, will retry', { error })
return true
},
onSuccess: (data) => {
if (data.attributed) {
logger.info('Referral attribution successful', { bonusAmount: data.bonusAmount })
} else if (data.error || TERMINAL_REASONS.has(data.reason ?? '')) {
logger.info('Referral attribution skipped', { reason: data.reason || data.error })
} else {
calledRef.current = false
}
},
onError: () => {
calledRef.current = false
},
})
useEffect(() => {
if (calledRef.current) return
if (!document.cookie.includes(COOKIE_NAME)) return
calledRef.current = true
mutate()
}, [mutate])
}

View File

@@ -0,0 +1,65 @@
import { db } from '@sim/db'
import { organization, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, sql } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import type { DbOrTx } from '@/lib/db/types'
const logger = createLogger('BonusCredits')
/**
* Apply bonus credits to a user (e.g. referral bonuses, promotional codes).
*
* Detects the user's current plan and routes credits accordingly:
* - Free/Pro: adds to `userStats.creditBalance` and increments `currentUsageLimit`
* - Team/Enterprise: adds to `organization.creditBalance` and increments `orgUsageLimit`
*
* Uses direct increment (not recalculation) so it works correctly for free-tier
* users where `setUsageLimitForCredits` would compute planBase=0 and skip the update.
*
* @param tx - Optional Drizzle transaction context. When provided, all DB writes
* participate in the caller's transaction for atomicity.
*/
export async function applyBonusCredits(
userId: string,
amount: number,
tx?: DbOrTx
): Promise<void> {
const dbCtx = tx ?? db
const subscription = await getHighestPrioritySubscription(userId)
const isTeamOrEnterprise = isOrgPlan(subscription?.plan)
if (isTeamOrEnterprise && subscription?.referenceId) {
const orgId = subscription.referenceId
await dbCtx
.update(organization)
.set({
creditBalance: sql`${organization.creditBalance} + ${amount}`,
orgUsageLimit: sql`COALESCE(${organization.orgUsageLimit}, '0')::decimal + ${amount}`,
})
.where(eq(organization.id, orgId))
logger.info('Applied bonus credits to organization', {
userId,
organizationId: orgId,
plan: subscription.plan,
amount,
})
} else {
await dbCtx
.update(userStats)
.set({
creditBalance: sql`${userStats.creditBalance} + ${amount}`,
currentUsageLimit: sql`COALESCE(${userStats.currentUsageLimit}, '0')::decimal + ${amount}`,
})
.where(eq(userStats.userId, userId))
logger.info('Applied bonus credits to user', {
userId,
plan: subscription?.plan || 'free',
amount,
})
}
}

View File

@@ -1,9 +1,9 @@
import { createLogger } from '@sim/logger'
import { getUserSubscriptionState } from '@/lib/billing/core/subscription'
import { processFileAttachments } from '@/lib/copilot/chat-context'
import { getCopilotToolDescription } from '@/lib/copilot/tool-descriptions'
import { isHosted } from '@/lib/core/config/feature-flags'
import { createMcpToolId } from '@/lib/mcp/utils'
import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getWorkflowById } from '@/lib/workflows/utils'
import { tools } from '@/tools/registry'
import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils'
@@ -126,47 +126,7 @@ export async function buildCopilotRequestPayload(
const effectiveMode = mode === 'agent' ? 'build' : mode
const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode
// Track uploaded files in the DB and build context tags instead of base64 inlining
const uploadContexts: Array<{ type: string; content: string }> = []
if (chatId && params.workspaceId && fileAttachments && fileAttachments.length > 0) {
for (const f of fileAttachments) {
const filename = (f.filename ?? f.name ?? 'file') as string
const mediaType = (f.media_type ?? f.mimeType ?? 'application/octet-stream') as string
try {
await trackChatUpload(
params.workspaceId,
userId,
chatId,
f.key,
filename,
mediaType,
f.size
)
const lines = [
`File "${filename}" (${mediaType}, ${f.size} bytes) uploaded.`,
`Read with: read("uploads/${filename}")`,
`To save permanently: materialize_file(fileName: "${filename}")`,
]
if (filename.endsWith('.json')) {
lines.push(
`To import as a workflow: materialize_file(fileName: "${filename}", operation: "import")`
)
}
uploadContexts.push({
type: 'uploaded_file',
content: lines.join('\n'),
})
} catch (err) {
logger.warn('Failed to track chat upload', {
filename,
chatId,
error: err instanceof Error ? err.message : String(err),
})
}
}
}
const allContexts = [...(contexts ?? []), ...uploadContexts]
const processedFileContents = await processFileAttachments(fileAttachments ?? [], userId)
let integrationTools: ToolSchema[] = []
@@ -210,10 +170,11 @@ export async function buildCopilotRequestPayload(
...(provider ? { provider } : {}),
mode: transportMode,
messageId: userMessageId,
...(allContexts.length > 0 ? { context: allContexts } : {}),
...(contexts && contexts.length > 0 ? { context: contexts } : {}),
...(chatId ? { chatId } : {}),
...(typeof prefetch === 'boolean' ? { prefetch } : {}),
...(implicitFeedback ? { implicitFeedback } : {}),
...(processedFileContents.length > 0 ? { fileAttachments: processedFileContents } : {}),
...(integrationTools.length > 0 ? { integrationTools } : {}),
...(commands && commands.length > 0 ? { commands } : {}),
...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}),

View File

@@ -47,7 +47,6 @@ import {
executeManageJob,
executeUpdateJobHistory,
} from './job-tools'
import { executeMaterializeFile } from './materialize-file'
import type {
CheckDeploymentStatusParams,
CreateFolderParams,
@@ -985,7 +984,6 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
},
}
},
materialize_file: (p, c) => executeMaterializeFile(p, c),
manage_custom_tool: (p, c) => executeManageCustomTool(p, c),
manage_mcp_tool: (p, c) => executeManageMcpTool(p, c),
manage_skill: (p, c) => executeManageSkill(p, c),

View File

@@ -1,221 +0,0 @@
import { db } from '@sim/db'
import { workflow, workspaceFiles } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import { getServePathPrefix } from '@/lib/uploads'
import { downloadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
import { extractWorkflowMetadata } from '@/app/api/v1/admin/types'
const logger = createLogger('MaterializeFile')
async function findUploadRecord(fileName: string, chatId: string) {
const rows = await db
.select()
.from(workspaceFiles)
.where(
and(
eq(workspaceFiles.originalName, fileName),
eq(workspaceFiles.chatId, chatId),
eq(workspaceFiles.context, 'mothership'),
isNull(workspaceFiles.deletedAt)
)
)
.limit(1)
return rows[0] ?? null
}
function toFileRecord(row: typeof workspaceFiles.$inferSelect) {
const pathPrefix = getServePathPrefix()
return {
id: row.id,
workspaceId: row.workspaceId || '',
name: row.originalName,
key: row.key,
path: `${pathPrefix}${encodeURIComponent(row.key)}?context=mothership`,
size: row.size,
type: row.contentType,
uploadedBy: row.userId,
deletedAt: row.deletedAt,
uploadedAt: row.uploadedAt,
}
}
async function executeSave(fileName: string, chatId: string): Promise<ToolCallResult> {
const [updated] = await db
.update(workspaceFiles)
.set({ context: 'workspace', chatId: null })
.where(
and(
eq(workspaceFiles.originalName, fileName),
eq(workspaceFiles.chatId, chatId),
eq(workspaceFiles.context, 'mothership'),
isNull(workspaceFiles.deletedAt)
)
)
.returning({ id: workspaceFiles.id, originalName: workspaceFiles.originalName })
if (!updated) {
return {
success: false,
error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`,
}
}
logger.info('Materialized file', { fileName, fileId: updated.id, chatId })
return {
success: true,
output: {
message: `File "${fileName}" materialized. It is now available at files/${fileName} and will persist independently of this chat.`,
fileId: updated.id,
path: `files/${fileName}`,
},
resources: [{ type: 'file', id: updated.id, title: fileName }],
}
}
async function executeImport(
fileName: string,
chatId: string,
workspaceId: string,
userId: string
): Promise<ToolCallResult> {
const row = await findUploadRecord(fileName, chatId)
if (!row) {
return {
success: false,
error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`,
}
}
const buffer = await downloadWorkspaceFile(toFileRecord(row))
const content = buffer.toString('utf-8')
let parsed: unknown
try {
parsed = JSON.parse(content)
} catch {
return { success: false, error: `"${fileName}" is not valid JSON.` }
}
const { data: workflowData, errors } = parseWorkflowJson(content)
if (!workflowData || errors.length > 0) {
return {
success: false,
error: `Invalid workflow JSON: ${errors.join(', ')}`,
}
}
const {
name: rawName,
color: workflowColor,
description: workflowDescription,
} = extractWorkflowMetadata(parsed)
const workflowId = crypto.randomUUID()
const now = new Date()
const dedupedName = await deduplicateWorkflowName(rawName, workspaceId, null)
await db.insert(workflow).values({
id: workflowId,
userId,
workspaceId,
folderId: null,
name: dedupedName,
description: workflowDescription,
color: workflowColor,
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData)
if (!saveResult.success) {
await db.delete(workflow).where(eq(workflow.id, workflowId))
return { success: false, error: `Failed to save workflow state: ${saveResult.error}` }
}
if (workflowData.variables && Array.isArray(workflowData.variables)) {
const variablesRecord: Record<
string,
{ id: string; name: string; type: string; value: unknown }
> = {}
for (const v of workflowData.variables) {
const varId = (v as { id?: string }).id || crypto.randomUUID()
const variable = v as { name: string; type?: string; value: unknown }
variablesRecord[varId] = {
id: varId,
name: variable.name,
type: variable.type || 'string',
value: variable.value,
}
}
await db
.update(workflow)
.set({ variables: variablesRecord, updatedAt: new Date() })
.where(eq(workflow.id, workflowId))
}
logger.info('Imported workflow from upload', {
fileName,
workflowId,
workflowName: dedupedName,
chatId,
})
return {
success: true,
output: {
message: `Workflow "${dedupedName}" imported successfully. It is now available in the workspace and can be edited or run.`,
workflowId,
workflowName: dedupedName,
},
resources: [{ type: 'workflow', id: workflowId, title: dedupedName }],
}
}
export async function executeMaterializeFile(
params: Record<string, unknown>,
context: ExecutionContext
): Promise<ToolCallResult> {
const fileName = params.fileName as string | undefined
if (!fileName) {
return { success: false, error: "Missing required parameter 'fileName'" }
}
if (!context.chatId) {
return { success: false, error: 'No chat context available for materialize_file' }
}
if (!context.workspaceId) {
return { success: false, error: 'No workspace context available for materialize_file' }
}
const operation = (params.operation as string | undefined) || 'save'
try {
if (operation === 'import') {
return await executeImport(fileName, context.chatId, context.workspaceId, context.userId)
}
return await executeSave(fileName, context.chatId)
} catch (err) {
logger.error('materialize_file failed', {
fileName,
operation,
chatId: context.chatId,
error: err instanceof Error ? err.message : String(err),
})
return {
success: false,
error: err instanceof Error ? err.message : 'Failed to materialize file',
}
}
}

View File

@@ -1,86 +0,0 @@
import { db } from '@sim/db'
import { workspaceFiles } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader'
import { getServePathPrefix } from '@/lib/uploads'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
const logger = createLogger('UploadFileReader')
function toWorkspaceFileRecord(row: typeof workspaceFiles.$inferSelect): WorkspaceFileRecord {
const pathPrefix = getServePathPrefix()
return {
id: row.id,
workspaceId: row.workspaceId || '',
name: row.originalName,
key: row.key,
path: `${pathPrefix}${encodeURIComponent(row.key)}?context=mothership`,
size: row.size,
type: row.contentType,
uploadedBy: row.userId,
deletedAt: row.deletedAt,
uploadedAt: row.uploadedAt,
}
}
/**
* List all chat-scoped uploads for a given chat.
*/
export async function listChatUploads(chatId: string): Promise<WorkspaceFileRecord[]> {
try {
const rows = await db
.select()
.from(workspaceFiles)
.where(
and(
eq(workspaceFiles.chatId, chatId),
eq(workspaceFiles.context, 'mothership'),
isNull(workspaceFiles.deletedAt)
)
)
return rows.map(toWorkspaceFileRecord)
} catch (err) {
logger.warn('Failed to list chat uploads', {
chatId,
error: err instanceof Error ? err.message : String(err),
})
return []
}
}
/**
* Read a specific uploaded file by name within a chat session.
*/
export async function readChatUpload(
filename: string,
chatId: string
): Promise<FileReadResult | null> {
try {
const rows = await db
.select()
.from(workspaceFiles)
.where(
and(
eq(workspaceFiles.chatId, chatId),
eq(workspaceFiles.context, 'mothership'),
eq(workspaceFiles.originalName, filename),
isNull(workspaceFiles.deletedAt)
)
)
.limit(1)
if (rows.length === 0) return null
const record = toWorkspaceFileRecord(rows[0])
return readFileRecord(record)
} catch (err) {
logger.warn('Failed to read chat upload', {
filename,
chatId,
error: err instanceof Error ? err.message : String(err),
})
return null
}
}

View File

@@ -1,10 +1,35 @@
import { createLogger } from '@sim/logger'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import type { MothershipResource } from '@/lib/copilot/resource-types'
import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types'
import type { WorkspaceVFS } from '@/lib/copilot/vfs'
import { getOrMaterializeVFS } from '@/lib/copilot/vfs'
import { listChatUploads, readChatUpload } from './upload-file-reader'
const logger = createLogger('VfsTools')
/**
* Resolves a VFS resource path to its resource descriptor by reading the
* sibling meta.json (already in memory) for the resource ID and name.
*/
function resolveVfsResource(vfs: WorkspaceVFS, path: string): MothershipResource | null {
const segments = path.split('/')
const resourceType = VFS_DIR_TO_RESOURCE[segments[0]]
if (!resourceType || !segments[1]) return null
const metaPath = `${segments[0]}/${segments[1]}/meta.json`
const meta = vfs.read(metaPath)
if (!meta) return null
try {
const parsed = JSON.parse(meta.content)
const id = parsed?.id as string | undefined
if (!id) return null
return { type: resourceType, id, title: (parsed.name as string) || segments[1] }
} catch {
return null
}
}
export async function executeVfsGrep(
params: Record<string, unknown>,
context: ExecutionContext
@@ -64,14 +89,7 @@ export async function executeVfsGlob(
try {
const vfs = await getOrMaterializeVFS(workspaceId, context.userId)
let files = vfs.glob(pattern)
if (context.chatId && (pattern === 'uploads/*' || pattern.startsWith('uploads/'))) {
const uploads = await listChatUploads(context.chatId)
const uploadPaths = uploads.map((f) => `uploads/${f.name}`)
files = [...files, ...uploadPaths]
}
const files = vfs.glob(pattern)
logger.debug('vfs_glob result', { pattern, fileCount: files.length })
return { success: true, output: { files } }
} catch (err) {
@@ -98,23 +116,6 @@ export async function executeVfsRead(
}
try {
// Handle chat-scoped uploads via the uploads/ virtual prefix
if (path.startsWith('uploads/')) {
if (!context.chatId) {
return { success: false, error: 'No chat context available for uploads/' }
}
const filename = path.slice('uploads/'.length)
const uploadResult = await readChatUpload(filename, context.chatId)
if (uploadResult) {
logger.debug('vfs_read resolved chat upload', { path, totalLines: uploadResult.totalLines })
return { success: true, output: uploadResult }
}
return {
success: false,
error: `Upload not found: ${path}. Use glob("uploads/*") to list available uploads.`,
}
}
const vfs = await getOrMaterializeVFS(workspaceId, context.userId)
const result = vfs.read(
path,
@@ -128,9 +129,12 @@ export async function executeVfsRead(
path,
totalLines: fileContent.totalLines,
})
// Appends metadata of resource to tool response
const resource = resolveVfsResource(vfs, path)
return {
success: true,
output: fileContent,
...(resource && { resources: [resource] }),
}
}
@@ -143,9 +147,12 @@ export async function executeVfsRead(
return { success: false, error: `File not found: ${path}.${hint}` }
}
logger.debug('vfs_read result', { path, totalLines: result.totalLines })
// Appends metadata of resource to tool response
const resource = resolveVfsResource(vfs, path)
return {
success: true,
output: result,
...(resource && { resources: [resource] }),
}
} catch (err) {
logger.error('vfs_read failed', {

View File

@@ -1,84 +0,0 @@
import { loggerMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetRedisClient, mockRedisSet } = vi.hoisted(() => ({
mockGetRedisClient: vi.fn(),
mockRedisSet: vi.fn(),
}))
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/core/config/redis', () => ({
getRedisClient: mockGetRedisClient,
}))
import { markExecutionCancelled } from './cancellation'
import {
abortManualExecution,
registerManualExecutionAborter,
unregisterManualExecutionAborter,
} from './manual-cancellation'
describe('markExecutionCancelled', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns redis_unavailable when no Redis client exists', async () => {
mockGetRedisClient.mockReturnValue(null)
await expect(markExecutionCancelled('execution-1')).resolves.toEqual({
durablyRecorded: false,
reason: 'redis_unavailable',
})
})
it('returns recorded when Redis write succeeds', async () => {
mockRedisSet.mockResolvedValue('OK')
mockGetRedisClient.mockReturnValue({ set: mockRedisSet })
await expect(markExecutionCancelled('execution-1')).resolves.toEqual({
durablyRecorded: true,
reason: 'recorded',
})
})
it('returns redis_write_failed when Redis write throws', async () => {
mockRedisSet.mockRejectedValue(new Error('set failed'))
mockGetRedisClient.mockReturnValue({ set: mockRedisSet })
await expect(markExecutionCancelled('execution-1')).resolves.toEqual({
durablyRecorded: false,
reason: 'redis_write_failed',
})
})
})
describe('manual execution cancellation registry', () => {
beforeEach(() => {
unregisterManualExecutionAborter('execution-1')
})
it('aborts registered executions', () => {
const abort = vi.fn()
registerManualExecutionAborter('execution-1', abort)
expect(abortManualExecution('execution-1')).toBe(true)
expect(abort).toHaveBeenCalledTimes(1)
})
it('returns false when no execution is registered', () => {
expect(abortManualExecution('execution-missing')).toBe(false)
})
it('unregisters executions', () => {
const abort = vi.fn()
registerManualExecutionAborter('execution-1', abort)
unregisterManualExecutionAborter('execution-1')
expect(abortManualExecution('execution-1')).toBe(false)
expect(abort).not.toHaveBeenCalled()
})
})

View File

@@ -6,36 +6,27 @@ const logger = createLogger('ExecutionCancellation')
const EXECUTION_CANCEL_PREFIX = 'execution:cancel:'
const EXECUTION_CANCEL_EXPIRY = 60 * 60
export type ExecutionCancellationRecordResult =
| { durablyRecorded: true; reason: 'recorded' }
| {
durablyRecorded: false
reason: 'redis_unavailable' | 'redis_write_failed'
}
export function isRedisCancellationEnabled(): boolean {
return getRedisClient() !== null
}
/**
* Mark an execution as cancelled in Redis.
* Returns whether the cancellation was durably recorded.
* Returns true if Redis is available and the flag was set, false otherwise.
*/
export async function markExecutionCancelled(
executionId: string
): Promise<ExecutionCancellationRecordResult> {
export async function markExecutionCancelled(executionId: string): Promise<boolean> {
const redis = getRedisClient()
if (!redis) {
return { durablyRecorded: false, reason: 'redis_unavailable' }
return false
}
try {
await redis.set(`${EXECUTION_CANCEL_PREFIX}${executionId}`, '1', 'EX', EXECUTION_CANCEL_EXPIRY)
logger.info('Marked execution as cancelled', { executionId })
return { durablyRecorded: true, reason: 'recorded' }
return true
} catch (error) {
logger.error('Failed to mark execution as cancelled', { executionId, error })
return { durablyRecorded: false, reason: 'redis_write_failed' }
return false
}
}

View File

@@ -1,19 +0,0 @@
const activeExecutionAborters = new Map<string, () => void>()
export function registerManualExecutionAborter(executionId: string, abort: () => void): void {
activeExecutionAborters.set(executionId, abort)
}
export function unregisterManualExecutionAborter(executionId: string): void {
activeExecutionAborters.delete(executionId)
}
export function abortManualExecution(executionId: string): boolean {
const abort = activeExecutionAborters.get(executionId)
if (!abort) {
return false
}
abort()
return true
}

View File

@@ -153,7 +153,6 @@ function getS3Config(context: StorageContext): StorageConfig {
bucket: S3_EXECUTION_FILES_CONFIG.bucket,
region: S3_EXECUTION_FILES_CONFIG.region,
}
case 'mothership':
case 'workspace':
return {
bucket: S3_CONFIG.bucket,
@@ -210,7 +209,6 @@ function getBlobConfig(context: StorageContext): StorageConfig {
connectionString: BLOB_EXECUTION_FILES_CONFIG.connectionString,
containerName: BLOB_EXECUTION_FILES_CONFIG.containerName,
}
case 'mothership':
case 'workspace':
return {
accountName: BLOB_CONFIG.accountName,

View File

@@ -207,37 +207,6 @@ export async function uploadWorkspaceFile(
}
}
/**
* Track a file that was already uploaded to workspace S3 as a chat-scoped upload.
* Creates a workspaceFiles record with context='mothership' and the given chatId.
* No S3 operations -- the file is already in storage from the presigned/upload step.
*/
export async function trackChatUpload(
workspaceId: string,
userId: string,
chatId: string,
s3Key: string,
fileName: string,
contentType: string,
size: number
): Promise<void> {
const fileId = `wf_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
await db.insert(workspaceFiles).values({
id: fileId,
key: s3Key,
userId,
workspaceId,
context: 'mothership',
chatId,
originalName: fileName,
contentType,
size,
})
logger.info(`Tracked chat upload: ${fileName} for chat ${chatId}`)
}
/**
* Check if a file with the same name already exists in workspace
*/

View File

@@ -2,7 +2,6 @@ export type StorageContext =
| 'knowledge-base'
| 'chat'
| 'copilot'
| 'mothership'
| 'execution'
| 'workspace'
| 'profile-pictures'

View File

@@ -137,6 +137,36 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null {
return null
}
const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content'] as const
const UTM_COOKIE_NAME = 'sim_utm'
const UTM_COOKIE_MAX_AGE = 3600
/**
* Sets a `sim_utm` cookie when UTM params are present on auth pages.
* Captures UTM values, the HTTP Referer, landing page, and a timestamp.
*/
function setUtmCookie(request: NextRequest, response: NextResponse): void {
const { searchParams, pathname } = request.nextUrl
const hasUtm = UTM_KEYS.some((key) => searchParams.get(key))
if (!hasUtm) return
const utmData: Record<string, string> = {}
for (const key of UTM_KEYS) {
const value = searchParams.get(key)
if (value) utmData[key] = value
}
utmData.referrer_url = request.headers.get('referer') || ''
utmData.landing_page = pathname
utmData.created_at = Date.now().toString()
response.cookies.set(UTM_COOKIE_NAME, JSON.stringify(utmData), {
path: '/',
maxAge: UTM_COOKIE_MAX_AGE,
sameSite: 'lax',
httpOnly: false, // Client-side hook needs to detect cookie presence
})
}
export async function proxy(request: NextRequest) {
const url = request.nextUrl
@@ -148,10 +178,13 @@ export async function proxy(request: NextRequest) {
if (url.pathname === '/login' || url.pathname === '/signup') {
if (hasActiveSession) {
return NextResponse.redirect(new URL('/workspace', request.url))
const redirect = NextResponse.redirect(new URL('/workspace', request.url))
setUtmCookie(request, redirect)
return redirect
}
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
setUtmCookie(request, response)
return response
}

View File

@@ -38,11 +38,12 @@ export const scrapeTool: ToolConfig<ScrapeParams, ScrapeResponse> = {
pricing: {
type: 'custom',
getCost: (_params, output) => {
const creditsUsed = (output.metadata as { creditsUsed?: number })?.creditsUsed
if (creditsUsed == null) {
const creditsUsedString = (output.metadata as { creditsUsed?: number })?.creditsUsed
if (creditsUsedString == null) {
throw new Error('Firecrawl response missing creditsUsed field')
}
const creditsUsed = Number(creditsUsedString)
if (Number.isNaN(creditsUsed)) {
throw new Error('Firecrawl response returned a non-numeric creditsUsed field')
}
@@ -110,7 +111,7 @@ export const scrapeTool: ToolConfig<ScrapeParams, ScrapeResponse> = {
markdown: data.data.markdown,
html: data.data.html,
metadata: data.data.metadata,
creditsUsed: data.creditsUsed,
creditsUsed: data.data.metadata?.creditsUsed,
},
}
},

View File

@@ -1,110 +0,0 @@
import { beforeAll, describe, expect, it } from 'vitest'
import { requestTool } from '@/tools/http/request'
import type { RequestParams } from '@/tools/http/types'
beforeAll(() => {
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
})
describe('HTTP Request Tool - Stringified Params Fix', () => {
it('should handle stringified params from UI storage', () => {
const stringifiedParams = JSON.stringify([
{ id: 'test-1', cells: { Key: 'id', Value: '311861947611' } },
{ id: 'test-2', cells: { Key: 'language', Value: 'tr' } },
])
const stringifiedHeaders = JSON.stringify([
{ id: 'test-3', cells: { Key: 'Authorization', Value: 'Bearer token' } },
])
const params = {
url: 'https://api.example.com/tracking',
method: 'GET' as const,
params: stringifiedParams,
headers: stringifiedHeaders,
}
const url = (requestTool.request.url as (params: RequestParams) => string)(params)
expect(url).toBe('https://api.example.com/tracking?id=311861947611&language=tr')
const headers = (
requestTool.request.headers as (params: RequestParams) => Record<string, string>
)(params)
expect(headers.Authorization).toBe('Bearer token')
})
it('should still handle normal array params', () => {
const params = {
url: 'https://api.example.com/tracking',
method: 'GET' as const,
params: [
{ id: 'test-1', cells: { Key: 'id', Value: '311861947611' } },
{ id: 'test-2', cells: { Key: 'language', Value: 'tr' } },
],
headers: [{ id: 'test-3', cells: { Key: 'Authorization', Value: 'Bearer token' } }],
}
const url = (requestTool.request.url as (params: RequestParams) => string)(params)
expect(url).toBe('https://api.example.com/tracking?id=311861947611&language=tr')
const headers = (
requestTool.request.headers as (params: RequestParams) => Record<string, string>
)(params)
expect(headers.Authorization).toBe('Bearer token')
})
it('should handle null and undefined params gracefully', () => {
const params = {
url: 'https://api.example.com/test',
method: 'GET' as const,
}
const url = (requestTool.request.url as (params: RequestParams) => string)(params)
expect(url).toBe('https://api.example.com/test')
const headers = (
requestTool.request.headers as (params: RequestParams) => Record<string, string>
)(params)
expect(headers).toBeDefined()
})
it('should handle stringified object params and headers', () => {
const params = {
url: 'https://api.example.com/oauth/token',
method: 'POST' as const,
body: { grant_type: 'client_credentials' },
params: JSON.stringify({ q: 'test' }),
headers: JSON.stringify({ 'Content-Type': 'application/x-www-form-urlencoded' }),
}
const url = (requestTool.request.url as (input: RequestParams) => string)(params)
expect(url).toBe('https://api.example.com/oauth/token?q=test')
const headers = (
requestTool.request.headers as (input: RequestParams) => Record<string, string>
)(params)
expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded')
const body = (
requestTool.request.body as (input: RequestParams) => Record<string, any> | string | FormData
)(params)
expect(body).toBe('grant_type=client_credentials')
})
it('should handle invalid JSON strings gracefully', () => {
const params = {
url: 'https://api.example.com/test',
method: 'GET' as const,
params: 'not-valid-json',
headers: '{broken',
}
const url = (requestTool.request.url as (input: RequestParams) => string)(params)
expect(url).toBe('https://api.example.com/test')
const headers = (
requestTool.request.headers as (input: RequestParams) => Record<string, string>
)(params)
expect(headers).toBeDefined()
})
})

View File

@@ -79,6 +79,7 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
request: {
url: (params: RequestParams) => {
// Process the URL once and cache the result
return processUrl(params.url, params.pathParams, params.params)
},
@@ -114,6 +115,7 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
}
if (params.body) {
// Check if user wants URL-encoded form data
const headers = transformTable(params.headers || null)
const contentType = headers['Content-Type'] || headers['content-type']

View File

@@ -3,9 +3,9 @@ import type { HttpMethod, TableRow, ToolResponse } from '@/tools/types'
export interface RequestParams {
url: string
method?: HttpMethod
headers?: TableRow[] | string
headers?: TableRow[]
body?: unknown
params?: TableRow[] | string
params?: TableRow[]
pathParams?: Record<string, string>
formData?: Record<string, string | Blob>
timeout?: number

View File

@@ -50,7 +50,7 @@ export const getDefaultHeaders = (
export const processUrl = (
url: string,
pathParams?: Record<string, string>,
queryParams?: TableRow[] | Record<string, any> | string | null
queryParams?: TableRow[] | null
): string => {
if ((url.startsWith('"') && url.endsWith('"')) || (url.startsWith("'") && url.endsWith("'"))) {
url = url.slice(1, -1)

View File

@@ -1,2 +0,0 @@
DROP TABLE "referral_attribution" CASCADE;--> statement-breakpoint
DROP TABLE "referral_campaigns" CASCADE;

View File

@@ -1,3 +0,0 @@
ALTER TABLE "workspace_files" ADD COLUMN "chat_id" uuid;--> statement-breakpoint
ALTER TABLE "workspace_files" ADD CONSTRAINT "workspace_files_chat_id_copilot_chats_id_fk" FOREIGN KEY ("chat_id") REFERENCES "public"."copilot_chats"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "workspace_files_chat_id_idx" ON "workspace_files" USING btree ("chat_id");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1212,20 +1212,6 @@
"when": 1773395340207,
"tag": "0173_bored_zeigeist",
"breakpoints": true
},
{
"idx": 174,
"version": "7",
"when": 1773529490946,
"tag": "0174_whole_lyja",
"breakpoints": true
},
{
"idx": 175,
"version": "7",
"when": 1773532070072,
"tag": "0175_cheerful_shockwave",
"breakpoints": true
}
]
}

Some files were not shown because too many files have changed in this diff Show More