diff --git a/apps/sim/app/(auth)/auth-layout-client.tsx b/apps/sim/app/(auth)/auth-layout-client.tsx
new file mode 100644
index 0000000000..e2b3e9489d
--- /dev/null
+++ b/apps/sim/app/(auth)/auth-layout-client.tsx
@@ -0,0 +1,37 @@
+'use client'
+
+import { useEffect } from 'react'
+import AuthBackground from '@/app/(auth)/components/auth-background'
+import Nav from '@/app/(landing)/components/nav/nav'
+
+function isColorDark(hexColor: string): boolean {
+ const hex = hexColor.replace('#', '')
+ const r = Number.parseInt(hex.substr(0, 2), 16)
+ const g = Number.parseInt(hex.substr(2, 2), 16)
+ const b = Number.parseInt(hex.substr(4, 2), 16)
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
+ return luminance < 0.5
+}
+
+export default function AuthLayoutClient({ children }: { children: React.ReactNode }) {
+ useEffect(() => {
+ const rootStyle = getComputedStyle(document.documentElement)
+ const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim()
+
+ if (brandBackground && isColorDark(brandBackground)) {
+ document.body.classList.add('auth-dark-bg')
+ } else {
+ document.body.classList.remove('auth-dark-bg')
+ }
+ }, [])
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/(auth)/layout.tsx b/apps/sim/app/(auth)/layout.tsx
index aaa76e0bf1..d0fdf75a49 100644
--- a/apps/sim/app/(auth)/layout.tsx
+++ b/apps/sim/app/(auth)/layout.tsx
@@ -1,42 +1,10 @@
-'use client'
+import type { Metadata } from 'next'
+import AuthLayoutClient from '@/app/(auth)/auth-layout-client'
-import { useEffect } from 'react'
-import AuthBackground from '@/app/(auth)/components/auth-background'
-import Nav from '@/app/(landing)/components/nav/nav'
-
-// Helper to detect if a color is dark
-function isColorDark(hexColor: string): boolean {
- const hex = hexColor.replace('#', '')
- const r = Number.parseInt(hex.substr(0, 2), 16)
- const g = Number.parseInt(hex.substr(2, 2), 16)
- const b = Number.parseInt(hex.substr(4, 2), 16)
- const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
- return luminance < 0.5
+export const metadata: Metadata = {
+ robots: { index: false, follow: false },
}
export default function AuthLayout({ children }: { children: React.ReactNode }) {
- useEffect(() => {
- // Check if brand background is dark and add class accordingly
- const rootStyle = getComputedStyle(document.documentElement)
- const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim()
-
- if (brandBackground && isColorDark(brandBackground)) {
- document.body.classList.add('auth-dark-bg')
- } else {
- document.body.classList.remove('auth-dark-bg')
- }
- }, [])
- return (
-
-
- {/* Header - Nav handles all conditional logic */}
-
-
- {/* Content */}
-
-
-
- )
+ return {children}
}
diff --git a/apps/sim/app/(auth)/login/page.tsx b/apps/sim/app/(auth)/login/page.tsx
index d0173542b8..255b170bfd 100644
--- a/apps/sim/app/(auth)/login/page.tsx
+++ b/apps/sim/app/(auth)/login/page.tsx
@@ -1,6 +1,11 @@
+import type { Metadata } from 'next'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import LoginForm from '@/app/(auth)/login/login-form'
+export const metadata: Metadata = {
+ title: 'Log In',
+}
+
export const dynamic = 'force-dynamic'
export default async function LoginPage() {
diff --git a/apps/sim/app/(auth)/reset-password/page.tsx b/apps/sim/app/(auth)/reset-password/page.tsx
index 29ef3e425c..cb6470bba0 100644
--- a/apps/sim/app/(auth)/reset-password/page.tsx
+++ b/apps/sim/app/(auth)/reset-password/page.tsx
@@ -1,117 +1,8 @@
-'use client'
+import type { Metadata } from 'next'
+import ResetPasswordPage from '@/app/(auth)/reset-password/reset-password-content'
-import { Suspense, useEffect, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import Link from 'next/link'
-import { useRouter, useSearchParams } from 'next/navigation'
-import { inter } from '@/app/_styles/fonts/inter/inter'
-import { soehne } from '@/app/_styles/fonts/soehne/soehne'
-import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
-
-const logger = createLogger('ResetPasswordPage')
-
-function ResetPasswordContent() {
- const router = useRouter()
- const searchParams = useSearchParams()
- const token = searchParams.get('token')
-
- const [isSubmitting, setIsSubmitting] = useState(false)
- const [statusMessage, setStatusMessage] = useState<{
- type: 'success' | 'error' | null
- text: string
- }>({
- type: null,
- text: '',
- })
-
- useEffect(() => {
- if (!token) {
- setStatusMessage({
- type: 'error',
- text: 'Invalid or missing reset token. Please request a new password reset link.',
- })
- }
- }, [token])
-
- const handleResetPassword = async (password: string) => {
- try {
- setIsSubmitting(true)
- setStatusMessage({ type: null, text: '' })
-
- const response = await fetch('/api/auth/reset-password', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- token,
- newPassword: password,
- }),
- })
-
- if (!response.ok) {
- const errorData = await response.json()
- throw new Error(errorData.message || 'Failed to reset password')
- }
-
- setStatusMessage({
- type: 'success',
- text: 'Password reset successful! Redirecting to login...',
- })
-
- setTimeout(() => {
- router.push('/login?resetSuccess=true')
- }, 1500)
- } catch (error) {
- logger.error('Error resetting password:', { error })
- setStatusMessage({
- type: 'error',
- text: error instanceof Error ? error.message : 'Failed to reset password',
- })
- } finally {
- setIsSubmitting(false)
- }
- }
-
- return (
- <>
-
-
- Reset your password
-
-
- Enter a new password for your account
-
-
-
-
-
-
-
-
-
- Back to login
-
-
- >
- )
+export const metadata: Metadata = {
+ title: 'Reset Password',
}
-export default function ResetPasswordPage() {
- return (
- Loading...}
- >
-
-
- )
-}
+export default ResetPasswordPage
diff --git a/apps/sim/app/(auth)/reset-password/reset-password-content.tsx b/apps/sim/app/(auth)/reset-password/reset-password-content.tsx
new file mode 100644
index 0000000000..29ef3e425c
--- /dev/null
+++ b/apps/sim/app/(auth)/reset-password/reset-password-content.tsx
@@ -0,0 +1,117 @@
+'use client'
+
+import { Suspense, useEffect, useState } from 'react'
+import { createLogger } from '@sim/logger'
+import Link from 'next/link'
+import { useRouter, useSearchParams } from 'next/navigation'
+import { inter } from '@/app/_styles/fonts/inter/inter'
+import { soehne } from '@/app/_styles/fonts/soehne/soehne'
+import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
+
+const logger = createLogger('ResetPasswordPage')
+
+function ResetPasswordContent() {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const token = searchParams.get('token')
+
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [statusMessage, setStatusMessage] = useState<{
+ type: 'success' | 'error' | null
+ text: string
+ }>({
+ type: null,
+ text: '',
+ })
+
+ useEffect(() => {
+ if (!token) {
+ setStatusMessage({
+ type: 'error',
+ text: 'Invalid or missing reset token. Please request a new password reset link.',
+ })
+ }
+ }, [token])
+
+ const handleResetPassword = async (password: string) => {
+ try {
+ setIsSubmitting(true)
+ setStatusMessage({ type: null, text: '' })
+
+ const response = await fetch('/api/auth/reset-password', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ token,
+ newPassword: password,
+ }),
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.message || 'Failed to reset password')
+ }
+
+ setStatusMessage({
+ type: 'success',
+ text: 'Password reset successful! Redirecting to login...',
+ })
+
+ setTimeout(() => {
+ router.push('/login?resetSuccess=true')
+ }, 1500)
+ } catch (error) {
+ logger.error('Error resetting password:', { error })
+ setStatusMessage({
+ type: 'error',
+ text: error instanceof Error ? error.message : 'Failed to reset password',
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+ <>
+
+
+ Reset your password
+
+
+ Enter a new password for your account
+
+
+
+
+
+
+
+
+
+ Back to login
+
+
+ >
+ )
+}
+
+export default function ResetPasswordPage() {
+ return (
+ Loading...}
+ >
+
+
+ )
+}
diff --git a/apps/sim/app/(auth)/signup/page.tsx b/apps/sim/app/(auth)/signup/page.tsx
index b267716e35..1f01e00464 100644
--- a/apps/sim/app/(auth)/signup/page.tsx
+++ b/apps/sim/app/(auth)/signup/page.tsx
@@ -1,7 +1,12 @@
+import type { Metadata } from 'next'
import { isRegistrationDisabled } from '@/lib/core/config/feature-flags'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import SignupForm from '@/app/(auth)/signup/signup-form'
+export const metadata: Metadata = {
+ title: 'Sign Up',
+}
+
export const dynamic = 'force-dynamic'
export default async function SignupPage() {
diff --git a/apps/sim/app/(auth)/sso/page.tsx b/apps/sim/app/(auth)/sso/page.tsx
index 49bf30f1c6..b49d114dab 100644
--- a/apps/sim/app/(auth)/sso/page.tsx
+++ b/apps/sim/app/(auth)/sso/page.tsx
@@ -1,7 +1,12 @@
+import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import SSOForm from '@/ee/sso/components/sso-form'
+export const metadata: Metadata = {
+ title: 'Single Sign-On',
+}
+
export const dynamic = 'force-dynamic'
export default async function SSOPage() {
diff --git a/apps/sim/app/(auth)/verify/page.tsx b/apps/sim/app/(auth)/verify/page.tsx
index 0402e09db6..70c5484144 100644
--- a/apps/sim/app/(auth)/verify/page.tsx
+++ b/apps/sim/app/(auth)/verify/page.tsx
@@ -1,7 +1,12 @@
+import type { Metadata } from 'next'
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/feature-flags'
import { hasEmailService } from '@/lib/messaging/email/mailer'
import { VerifyContent } from '@/app/(auth)/verify/verify-content'
+export const metadata: Metadata = {
+ title: 'Verify Email',
+}
+
export const dynamic = 'force-dynamic'
export default function VerifyPage() {
diff --git a/apps/sim/app/(landing)/layout.tsx b/apps/sim/app/(landing)/layout.tsx
index a3ecdf739c..9fef82ff68 100644
--- a/apps/sim/app/(landing)/layout.tsx
+++ b/apps/sim/app/(landing)/layout.tsx
@@ -4,8 +4,8 @@ export const metadata: Metadata = {
metadataBase: new URL('https://sim.ai'),
manifest: '/manifest.json',
icons: {
- icon: '/favicon.ico',
- apple: '/apple-icon.png',
+ icon: [{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' }],
+ apple: '/favicon/apple-touch-icon.png',
},
other: {
'msapplication-TileColor': '#000000',
diff --git a/apps/sim/app/(landing)/studio/authors/[id]/page.tsx b/apps/sim/app/(landing)/studio/authors/[id]/page.tsx
index 720020bbdf..17afbe6cda 100644
--- a/apps/sim/app/(landing)/studio/authors/[id]/page.tsx
+++ b/apps/sim/app/(landing)/studio/authors/[id]/page.tsx
@@ -1,3 +1,4 @@
+import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { getAllPostMeta } from '@/lib/blog/registry'
@@ -5,6 +6,17 @@ import { soehne } from '@/app/_styles/fonts/soehne/soehne'
export const revalidate = 3600
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ id: string }>
+}): Promise {
+ const { id } = await params
+ const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
+ const author = posts[0]?.author
+ return { title: author?.name ?? 'Author' }
+}
+
export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
diff --git a/apps/sim/app/(landing)/studio/page.tsx b/apps/sim/app/(landing)/studio/page.tsx
index fed9fdf1be..f1fe274366 100644
--- a/apps/sim/app/(landing)/studio/page.tsx
+++ b/apps/sim/app/(landing)/studio/page.tsx
@@ -1,8 +1,14 @@
+import type { Metadata } from 'next'
import Link from 'next/link'
import { getAllPostMeta } from '@/lib/blog/registry'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { PostGrid } from '@/app/(landing)/studio/post-grid'
+export const metadata: Metadata = {
+ title: 'Studio',
+ description: 'Announcements, insights, and guides from the Sim team.',
+}
+
export const revalidate = 3600
export default async function StudioIndex({
diff --git a/apps/sim/app/(landing)/studio/tags/page.tsx b/apps/sim/app/(landing)/studio/tags/page.tsx
index 9650b5d1da..7f9c2d0c2c 100644
--- a/apps/sim/app/(landing)/studio/tags/page.tsx
+++ b/apps/sim/app/(landing)/studio/tags/page.tsx
@@ -1,6 +1,11 @@
+import type { Metadata } from 'next'
import Link from 'next/link'
import { getAllTags } from '@/lib/blog/registry'
+export const metadata: Metadata = {
+ title: 'Tags',
+}
+
export default async function TagsIndex() {
const tags = await getAllTags()
return (
diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts
new file mode 100644
index 0000000000..b544ce8d59
--- /dev/null
+++ b/apps/sim/app/api/table/[tableId]/columns/route.ts
@@ -0,0 +1,77 @@
+import { createLogger } from '@sim/logger'
+import { type NextRequest, NextResponse } from 'next/server'
+import { z } from 'zod'
+import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { addTableColumn } from '@/lib/table'
+import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
+
+const logger = createLogger('TableColumnsAPI')
+
+const CreateColumnSchema = z.object({
+ workspaceId: z.string().min(1, 'Workspace ID is required'),
+ column: z.object({
+ name: z.string().min(1, 'Column name is required'),
+ type: z.enum(['string', 'number', 'boolean', 'date', 'json']),
+ required: z.boolean().optional(),
+ unique: z.boolean().optional(),
+ }),
+})
+
+interface ColumnsRouteParams {
+ params: Promise<{ tableId: string }>
+}
+
+/** POST /api/table/[tableId]/columns - Adds a column to the table schema. */
+export async function POST(request: NextRequest, { params }: ColumnsRouteParams) {
+ const requestId = generateRequestId()
+ const { tableId } = await params
+
+ try {
+ const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!authResult.success || !authResult.userId) {
+ logger.warn(`[${requestId}] Unauthorized column creation attempt`)
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+ }
+
+ const body = await request.json()
+ const validated = CreateColumnSchema.parse(body)
+
+ const result = await checkAccess(tableId, authResult.userId, 'write')
+ if (!result.ok) return accessError(result, requestId, tableId)
+
+ const { table } = result
+
+ if (table.workspaceId !== validated.workspaceId) {
+ return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
+ }
+
+ const updatedTable = await addTableColumn(tableId, validated.column, requestId)
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ columns: updatedTable.schema.columns.map(normalizeColumn),
+ },
+ })
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'Invalid request data', details: error.errors },
+ { status: 400 }
+ )
+ }
+
+ if (error instanceof Error) {
+ if (error.message.includes('already exists') || error.message.includes('maximum column')) {
+ return NextResponse.json({ error: error.message }, { status: 400 })
+ }
+ if (error.message === 'Table not found') {
+ return NextResponse.json({ error: error.message }, { status: 404 })
+ }
+ }
+
+ logger.error(`[${requestId}] Error adding column to table ${tableId}:`, error)
+ return NextResponse.json({ error: 'Failed to add column' }, { status: 500 })
+ }
+}
diff --git a/apps/sim/app/chat/[identifier]/page.tsx b/apps/sim/app/chat/[identifier]/page.tsx
index 9ba983fc5c..a90238b644 100644
--- a/apps/sim/app/chat/[identifier]/page.tsx
+++ b/apps/sim/app/chat/[identifier]/page.tsx
@@ -1,5 +1,10 @@
+import type { Metadata } from 'next'
import ChatClient from '@/app/chat/[identifier]/chat'
+export const metadata: Metadata = {
+ title: 'Chat',
+}
+
export default async function ChatPage({ params }: { params: Promise<{ identifier: string }> }) {
const { identifier } = await params
return
diff --git a/apps/sim/app/favicon.ico b/apps/sim/app/favicon.ico
deleted file mode 100644
index 9aa82bcf04..0000000000
Binary files a/apps/sim/app/favicon.ico and /dev/null differ
diff --git a/apps/sim/app/form/[identifier]/page.tsx b/apps/sim/app/form/[identifier]/page.tsx
index ba4004789d..10f5a6e7fc 100644
--- a/apps/sim/app/form/[identifier]/page.tsx
+++ b/apps/sim/app/form/[identifier]/page.tsx
@@ -1,5 +1,10 @@
+import type { Metadata } from 'next'
import Form from '@/app/form/[identifier]/form'
+export const metadata: Metadata = {
+ title: 'Form',
+}
+
export default async function FormPage({ params }: { params: Promise<{ identifier: string }> }) {
const { identifier } = await params
return
diff --git a/apps/sim/app/invite/[id]/page.tsx b/apps/sim/app/invite/[id]/page.tsx
index 2f22144abc..e04a2ca774 100644
--- a/apps/sim/app/invite/[id]/page.tsx
+++ b/apps/sim/app/invite/[id]/page.tsx
@@ -1,3 +1,9 @@
+import type { Metadata } from 'next'
import Invite from '@/app/invite/[id]/invite'
+export const metadata: Metadata = {
+ title: 'Invite',
+ robots: { index: false },
+}
+
export default Invite
diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/page.tsx b/apps/sim/app/resume/[workflowId]/[executionId]/page.tsx
index bfd2e2fac2..8ad86d3222 100644
--- a/apps/sim/app/resume/[workflowId]/[executionId]/page.tsx
+++ b/apps/sim/app/resume/[workflowId]/[executionId]/page.tsx
@@ -1,6 +1,12 @@
+import type { Metadata } from 'next'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import ResumeExecutionPage from '@/app/resume/[workflowId]/[executionId]/resume-page-client'
+export const metadata: Metadata = {
+ title: 'Resume Execution',
+ robots: { index: false },
+}
+
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
diff --git a/apps/sim/app/templates/page.tsx b/apps/sim/app/templates/page.tsx
index c233949a45..c74818acd3 100644
--- a/apps/sim/app/templates/page.tsx
+++ b/apps/sim/app/templates/page.tsx
@@ -1,11 +1,18 @@
import { db } from '@sim/db'
import { permissions, templateCreators, templates, workspace } from '@sim/db/schema'
import { and, desc, eq } from 'drizzle-orm'
+import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import type { Template } from '@/app/templates/templates'
import Templates from '@/app/templates/templates'
+export const metadata: Metadata = {
+ title: 'Templates',
+ description:
+ 'Browse pre-built workflow templates to get started quickly with AI agents, automations, and integrations.',
+}
+
/**
* Public templates list page.
* Redirects authenticated users to their workspace-scoped templates page.
diff --git a/apps/sim/app/unsubscribe/page.tsx b/apps/sim/app/unsubscribe/page.tsx
index 8f6e6bf688..d1b3ec2de1 100644
--- a/apps/sim/app/unsubscribe/page.tsx
+++ b/apps/sim/app/unsubscribe/page.tsx
@@ -1,3 +1,9 @@
+import type { Metadata } from 'next'
import Unsubscribe from '@/app/unsubscribe/unsubscribe'
+export const metadata: Metadata = {
+ title: 'Unsubscribe',
+ robots: { index: false },
+}
+
export default Unsubscribe
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx
index 7fa82cc6fb..4469e50a03 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx
@@ -32,7 +32,7 @@ export function ResourceOptionsBar({
{search && (
-
+
{onFilter && (
)}
{onSort && (
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
index 0020321bdf..272afbc86e 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
@@ -137,7 +137,7 @@ export function Resource({
>
{col.header}
{sort.column === col.id && (
-
+
)}
@@ -210,7 +210,7 @@ function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean
primary ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]'
)}
>
- {cell.icon &&
{cell.icon}}
+ {cell.icon &&
{cell.icon}}
{cell.label}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/page.tsx
index 1f78f7e688..5333dfa4b8 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/page.tsx
@@ -1,9 +1,15 @@
+import type { Metadata } from 'next'
import { redirect, unstable_rethrow } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { FileViewer } from '@/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer'
+export const metadata: Metadata = {
+ title: 'File Viewer',
+ robots: { index: false },
+}
+
interface FileViewerPageProps {
params: Promise<{
workspaceId: string
diff --git a/apps/sim/app/workspace/[workspaceId]/files/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/page.tsx
index 68f8a6f835..3120469652 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/page.tsx
@@ -1,9 +1,15 @@
+import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { Files } from './files'
+export const metadata: Metadata = {
+ title: 'Files',
+ robots: { index: false },
+}
+
interface FilesPageProps {
params: Promise<{
workspaceId: string
diff --git a/apps/sim/app/workspace/[workspaceId]/home/page.tsx b/apps/sim/app/workspace/[workspaceId]/home/page.tsx
index b354c2c520..659b6e6865 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/page.tsx
@@ -1,8 +1,13 @@
+import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { Home } from './home'
+export const metadata: Metadata = {
+ title: 'Home',
+}
+
interface HomePageProps {
params: Promise<{
workspaceId: string
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx
index 93d484d4d8..be3b3d8530 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx
@@ -1,3 +1,4 @@
+import type { Metadata } from 'next'
import { Document } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document'
interface DocumentPageProps {
@@ -11,6 +12,13 @@ interface DocumentPageProps {
}>
}
+export async function generateMetadata({ searchParams }: DocumentPageProps): Promise
{
+ const { docName, kbName } = await searchParams
+ const title = docName || 'Document'
+ const parentName = kbName || 'Knowledge Base'
+ return { title: `${title} — ${parentName}` }
+}
+
export default async function DocumentChunksPage({ params, searchParams }: DocumentPageProps) {
const { id, documentId } = await params
const { kbName, docName } = await searchParams
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx
index 7bb810feb4..219f94d3e2 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx
@@ -1,3 +1,4 @@
+import type { Metadata } from 'next'
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
interface PageProps {
@@ -9,6 +10,11 @@ interface PageProps {
}>
}
+export async function generateMetadata({ searchParams }: PageProps): Promise {
+ const { kbName } = await searchParams
+ return { title: kbName || 'Knowledge Base' }
+}
+
export default async function KnowledgeBasePage({ params, searchParams }: PageProps) {
const { id } = await params
const { kbName } = await searchParams
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
index c62ee4efac..cd6d4c6211 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
@@ -100,10 +100,6 @@ export function Knowledge() {
setIsCreateModalOpen(true)
}, [])
- const handleSort = useCallback(() => {}, [])
-
- const handleFilter = useCallback(() => {}, [])
-
const handleUpdateKnowledgeBase = useCallback(
async (id: string, name: string, description: string) => {
await updateKnowledgeBaseMutation({
@@ -208,8 +204,8 @@ export function Knowledge() {
placeholder: 'Search knowledge bases...',
}}
defaultSort='created'
- onSort={handleSort}
- onFilter={handleFilter}
+ onSort={() => {}}
+ onFilter={() => {}}
columns={COLUMNS}
rows={rows}
onRowClick={handleRowClick}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx
index a449539d53..d52602721a 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx
@@ -1,9 +1,14 @@
+import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { Knowledge } from './knowledge'
+export const metadata: Metadata = {
+ title: 'Knowledge Base',
+}
+
interface KnowledgePageProps {
params: Promise<{
workspaceId: string
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx
index e4b06cfd5a..de24323dd8 100644
--- a/apps/sim/app/workspace/[workspaceId]/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx
@@ -12,7 +12,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
-
+
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/page.tsx b/apps/sim/app/workspace/[workspaceId]/logs/page.tsx
index ee3d9c0bcf..0b0d49ff33 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/page.tsx
@@ -1,3 +1,8 @@
+import type { Metadata } from 'next'
import Logs from '@/app/workspace/[workspaceId]/logs/logs'
+export const metadata: Metadata = {
+ title: 'Logs',
+}
+
export default Logs
diff --git a/apps/sim/app/workspace/[workspaceId]/schedules/page.tsx b/apps/sim/app/workspace/[workspaceId]/schedules/page.tsx
index ba726b9487..5e94455961 100644
--- a/apps/sim/app/workspace/[workspaceId]/schedules/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/schedules/page.tsx
@@ -1,8 +1,13 @@
+import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { Schedules } from './schedules'
+export const metadata: Metadata = {
+ title: 'Schedules',
+}
+
interface SchedulesPageProps {
params: Promise<{
workspaceId: string
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
index 718b3a8b88..8a8573987b 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
@@ -1,12 +1,40 @@
-'use client'
-
-import { useParams } from 'next/navigation'
+import type { Metadata } from 'next'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import { SettingsPage } from './settings'
-export default function SettingsSectionPage() {
- const params = useParams()
- const section = params.section as SettingsSection
+const SECTION_TITLES: Record
= {
+ general: 'General',
+ credentials: 'Secrets',
+ 'template-profile': 'Template Profile',
+ 'access-control': 'Access Control',
+ apikeys: 'Sim Keys',
+ byok: 'BYOK',
+ subscription: 'Subscription',
+ team: 'Team',
+ sso: 'Single Sign-On',
+ copilot: 'Copilot Keys',
+ mcp: 'MCP Tools',
+ 'custom-tools': 'Custom Tools',
+ skills: 'Skills',
+ 'workflow-mcp-servers': 'MCP Servers',
+ 'credential-sets': 'Email Polling',
+ debug: 'Debug',
+} as const
- return
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ section: string }>
+}): Promise {
+ const { section } = await params
+ return { title: SECTION_TITLES[section] ?? 'Settings' }
+}
+
+export default async function SettingsSectionPage({
+ params,
+}: {
+ params: Promise<{ workspaceId: string; section: string }>
+}) {
+ const { section } = await params
+ return
}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx
index ef1c007f13..600c6d8c8c 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx
@@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation'
import { Button, Checkbox, Skeleton } from '@/components/emcn'
import {
Calendar as CalendarIcon,
+ Plus,
Table as TableIcon,
TypeBoolean,
TypeJson,
@@ -14,17 +15,46 @@ import {
import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition, TableRow as TableRowType } from '@/lib/table'
import { ResourceHeader, ResourceOptionsBar } from '@/app/workspace/[workspaceId]/components'
-import { useCreateTableRow, useUpdateTableRow } from '@/hooks/queries/tables'
-import { STRING_TRUNCATE_LENGTH } from '../../constants'
+import { useAddTableColumn, useCreateTableRow, useUpdateTableRow } from '@/hooks/queries/tables'
import { useContextMenu, useRowSelection, useTableData } from '../../hooks'
-import type { CellViewerData, EditingCell, QueryOptions } from '../../types'
+import type { EditingCell, QueryOptions } from '../../types'
import { cleanCellValue, formatValueForInput } from '../../utils'
-import { CellViewerModal } from '../cell-viewer-modal'
import { ContextMenu } from '../context-menu'
import { RowModal } from '../row-modal'
import { SchemaModal } from '../schema-modal'
+type SelectedCell =
+ | { kind: 'data'; rowId: string; columnName: string }
+ | { kind: 'placeholder'; index: number; columnName: string }
+
+interface PendingPlaceholder {
+ rowId: string | null
+ data: Record
+}
+
const EMPTY_COLUMNS: never[] = []
+const PLACEHOLDER_ROW_COUNT = 50
+const COL_WIDTH = 160
+const CHECKBOX_COL_WIDTH = 40
+const ADD_COL_WIDTH = 120
+
+const CELL = 'border-[var(--border)] border-r border-b px-[8px] py-[7px] align-middle'
+const CELL_CHECKBOX = 'border-[var(--border)] border-r border-b px-[12px] py-[7px] align-middle'
+const CELL_HEADER =
+ 'border-[var(--border)] border-r border-b bg-white px-[8px] py-[7px] text-left align-middle dark:bg-[var(--bg)]'
+const CELL_HEADER_CHECKBOX =
+ 'border-[var(--border)] border-r border-b bg-white px-[12px] py-[7px] text-left align-middle dark:bg-[var(--bg)]'
+const CELL_CONTENT = 'relative min-h-[20px] min-w-0 overflow-hidden truncate text-[13px]'
+const SELECTION_OVERLAY =
+ 'pointer-events-none absolute -top-px -right-px -bottom-px -left-px border-2 border-[var(--brand-tertiary-2)]'
+
+const COLUMN_TYPE_ICONS: Record = {
+ string: TypeText,
+ number: TypeNumber,
+ boolean: TypeBoolean,
+ date: CalendarIcon,
+ json: TypeJson,
+}
export function Table() {
const params = useParams()
@@ -44,15 +74,16 @@ export function Table() {
const [deletingRows, setDeletingRows] = useState([])
const [showSchemaModal, setShowSchemaModal] = useState(false)
const [editingCell, setEditingCell] = useState(null)
+ const [selectedCell, setSelectedCell] = useState(null)
+ const [pendingPlaceholders, setPendingPlaceholders] = useState<
+ Record
+ >({})
const [editingEmptyCell, setEditingEmptyCell] = useState<{
rowIndex: number
columnName: string
} | null>(null)
- const [cellViewer, setCellViewer] = useState(null)
- const [copied, setCopied] = useState(false)
-
const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows } = useTableData({
workspaceId,
tableId,
@@ -70,17 +101,34 @@ export function Table() {
const updateRowMutation = useUpdateTableRow({ workspaceId, tableId })
const createRowMutation = useCreateTableRow({ workspaceId, tableId })
+ const addColumnMutation = useAddTableColumn({ workspaceId, tableId })
const columns = useMemo(
() => tableData?.schema?.columns || EMPTY_COLUMNS,
[tableData?.schema?.columns]
)
+
+ const pendingRowIds = useMemo(() => {
+ const ids = new Set()
+ for (const pending of Object.values(pendingPlaceholders)) {
+ if (pending.rowId) ids.add(pending.rowId)
+ }
+ return ids
+ }, [pendingPlaceholders])
+
+ const visibleRows = useMemo(
+ () => rows.filter((r) => !pendingRowIds.has(r.id)),
+ [rows, pendingRowIds]
+ )
+
+ const tableWidth = CHECKBOX_COL_WIDTH + columns.length * COL_WIDTH + ADD_COL_WIDTH
const selectedCount = selectedRows.size
const hasSelection = selectedCount > 0
- const isAllSelected = rows.length > 0 && selectedCount === rows.length
+ const isAllSelected = visibleRows.length > 0 && selectedCount === visibleRows.length
const columnsRef = useRef(columns)
const rowsRef = useRef(rows)
+ const pendingPlaceholdersRef = useRef(pendingPlaceholders)
useEffect(() => {
columnsRef.current = columns
@@ -88,6 +136,9 @@ export function Table() {
useEffect(() => {
rowsRef.current = rows
}, [rows])
+ useEffect(() => {
+ pendingPlaceholdersRef.current = pendingPlaceholders
+ }, [pendingPlaceholders])
const handleNavigateBack = useCallback(() => {
router.push(`/workspace/${workspaceId}/tables`)
@@ -97,10 +148,6 @@ export function Table() {
setShowAddModal(true)
}, [])
- const handleSort = useCallback(() => {}, [])
-
- const handleFilter = useCallback(() => {}, [])
-
const handleDeleteSelected = useCallback(() => {
setDeletingRows(Array.from(selectedRows))
}, [selectedRows])
@@ -127,30 +174,17 @@ export function Table() {
[baseHandleRowContextMenu]
)
- const handleCopyCellValue = useCallback(async () => {
- if (cellViewer) {
- let text: string
- if (cellViewer.type === 'json') {
- text = JSON.stringify(cellViewer.value, null, 2)
- } else if (cellViewer.type === 'date') {
- text = String(cellViewer.value)
- } else {
- text = String(cellViewer.value)
- }
- await navigator.clipboard.writeText(text)
- setCopied(true)
- setTimeout(() => setCopied(false), 2000)
- }
- }, [cellViewer])
+ const handleSelectCell = useCallback((rowId: string, columnName: string) => {
+ setSelectedCell({ kind: 'data', rowId, columnName })
+ }, [])
- const handleCellClick = useCallback(
- (columnName: string, value: unknown, type: CellViewerData['type']) => {
- setCellViewer({ columnName, value, type })
- },
- []
- )
+ const handleSelectPlaceholderCell = useCallback((index: number, columnName: string) => {
+ setSelectedCell({ kind: 'placeholder', index, columnName })
+ }, [])
const handleCellDoubleClick = useCallback((rowId: string, columnName: string) => {
+ setSelectedCell({ kind: 'data', rowId, columnName })
+
const column = columnsRef.current.find((c) => c.name === columnName)
if (!column) return
@@ -160,7 +194,13 @@ export function Table() {
return
}
- if (column.type === 'boolean') return
+ if (column.type === 'boolean') {
+ const row = rowsRef.current.find((r) => r.id === rowId)
+ if (row) {
+ mutateRef.current({ rowId, data: { [columnName]: !row.data[columnName] } })
+ }
+ return
+ }
setEditingCell({ rowId, columnName })
}, [])
@@ -170,6 +210,60 @@ export function Table() {
mutateRef.current = updateRowMutation.mutate
}, [updateRowMutation.mutate])
+ const selectedCellRef = useRef(selectedCell)
+ useEffect(() => {
+ selectedCellRef.current = selectedCell
+ }, [selectedCell])
+
+ const editingCellRef = useRef(editingCell)
+ useEffect(() => {
+ editingCellRef.current = editingCell
+ }, [editingCell])
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ const sel = selectedCellRef.current
+ if (!sel || sel.kind !== 'data' || editingCellRef.current) return
+
+ if (e.key === 'Delete' || e.key === 'Backspace') {
+ e.preventDefault()
+ mutateRef.current({ rowId: sel.rowId, data: { [sel.columnName]: null } })
+ }
+
+ if ((e.metaKey || e.ctrlKey) && e.key === 'c') {
+ e.preventDefault()
+ const row = rowsRef.current.find((r) => r.id === sel.rowId)
+ if (row) {
+ const value = row.data[sel.columnName]
+ let text = ''
+ if (value !== null && value !== undefined) {
+ text = typeof value === 'object' ? JSON.stringify(value) : String(value)
+ }
+ navigator.clipboard.writeText(text)
+ }
+ }
+
+ if ((e.metaKey || e.ctrlKey) && e.key === 'v') {
+ e.preventDefault()
+ navigator.clipboard.readText().then((text) => {
+ const current = selectedCellRef.current
+ if (!current || current.kind !== 'data') return
+ const column = columnsRef.current.find((c) => c.name === current.columnName)
+ if (!column) return
+ try {
+ const cleaned = cleanCellValue(text, column)
+ mutateRef.current({ rowId: current.rowId, data: { [current.columnName]: cleaned } })
+ } catch {
+ // ignore invalid paste values
+ }
+ })
+ }
+ }
+
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [])
+
const handleInlineSave = useCallback((rowId: string, columnName: string, value: unknown) => {
setEditingCell(null)
@@ -187,13 +281,6 @@ export function Table() {
setEditingCell(null)
}, [])
- const handleBooleanToggle = useCallback(
- (rowId: string, columnName: string, currentValue: boolean) => {
- mutateRef.current({ rowId, data: { [columnName]: !currentValue } })
- },
- []
- )
-
const handleEmptyRowDoubleClick = useCallback((rowIndex: number, columnName: string) => {
const column = columnsRef.current.find((c) => c.name === columnName)
if (!column || column.type === 'json' || column.type === 'boolean') return
@@ -205,16 +292,65 @@ export function Table() {
createRef.current = createRowMutation.mutate
}, [createRowMutation.mutate])
- const handleEmptyRowSave = useCallback((columnName: string, value: unknown) => {
+ const handleEmptyRowSave = useCallback((rowIndex: number, columnName: string, value: unknown) => {
setEditingEmptyCell(null)
if (value === null || value === undefined || value === '') return
- createRef.current({ [columnName]: value })
+
+ const existing = pendingPlaceholdersRef.current[rowIndex]
+ const updatedData = { ...(existing?.data || {}), [columnName]: value }
+
+ if (existing?.rowId) {
+ setPendingPlaceholders((prev) => ({
+ ...prev,
+ [rowIndex]: { ...prev[rowIndex], data: updatedData },
+ }))
+ mutateRef.current({ rowId: existing.rowId, data: { [columnName]: value } })
+ } else {
+ setPendingPlaceholders((prev) => ({
+ ...prev,
+ [rowIndex]: { rowId: null, data: updatedData },
+ }))
+ createRef.current(updatedData, {
+ onSuccess: (response: Record) => {
+ const data = response?.data as Record | undefined
+ const row = data?.row as Record | undefined
+ const newRowId = row?.id as string | undefined
+ if (newRowId) {
+ setPendingPlaceholders((prev) => {
+ if (!prev[rowIndex]) return prev
+ return {
+ ...prev,
+ [rowIndex]: { ...prev[rowIndex], rowId: newRowId },
+ }
+ })
+ }
+ },
+ onError: () => {
+ setPendingPlaceholders((prev) => {
+ const next = { ...prev }
+ delete next[rowIndex]
+ return next
+ })
+ },
+ })
+ }
}, [])
const handleEmptyRowCancel = useCallback(() => {
setEditingEmptyCell(null)
}, [])
+ const handleAddColumn = useCallback(() => {
+ const existing = columnsRef.current.map((c) => c.name.toLowerCase())
+ let name = 'untitled'
+ let i = 2
+ while (existing.includes(name.toLowerCase())) {
+ name = `untitled_${i}`
+ i++
+ }
+ addColumnMutation.mutate({ name, type: 'string' })
+ }, [addColumnMutation])
+
if (isLoadingTable) {
return (
@@ -238,34 +374,42 @@ export function Table() {
breadcrumbs={[{ label: 'Tables', onClick: handleNavigateBack }, { label: tableData.name }]}
/>
-
+
{}} onFilter={() => {}} />
-
-
-
- {columns.map((col) => (
-
- ))}
-
-
+
+
+
- |
+ |
|
{columns.map((column) => (
-
-
+
+
-
+
{column.name}
|
))}
+
+
+ |
|
@@ -273,7 +417,7 @@ export function Table() {
) : (
<>
- {rows.map((row) => (
+ {visibleRows.map((row) => (
))}
>
)}
@@ -318,9 +469,7 @@ export function Table() {
isOpen={true}
onClose={() => setShowAddModal(false)}
table={tableData}
- onSuccess={() => {
- setShowAddModal(false)
- }}
+ onSuccess={() => setShowAddModal(false)}
/>
)}
@@ -331,9 +480,7 @@ export function Table() {
onClose={() => setEditingRow(null)}
table={tableData}
row={editingRow}
- onSuccess={() => {
- setEditingRow(null)
- }}
+ onSuccess={() => setEditingRow(null)}
/>
)}
@@ -358,13 +505,6 @@ export function Table() {
tableName={tableData.name}
/>
- setCellViewer(null)}
- onCopy={handleCopyCellValue}
- copied={copied}
- />
-
+
+ {columns.map((col) => (
+
+ ))}
+
+
+ )
+}
+
+function SelectionOverlay() {
+ return
+}
+
const DataRow = React.memo(function DataRow({
row,
columns,
isSelected,
editingColumnName,
- onCellClick,
+ selectedColumnName,
onDoubleClick,
onSave,
onCancel,
- onBooleanToggle,
onContextMenu,
onSelectRow,
+ onSelectCell,
}: {
row: TableRowType
columns: ColumnDefinition[]
isSelected: boolean
editingColumnName: string | null
- onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
+ selectedColumnName: string | null
onDoubleClick: (rowId: string, columnName: string) => void
onSave: (rowId: string, columnName: string, value: unknown) => void
onCancel: () => void
- onBooleanToggle: (rowId: string, columnName: string, currentValue: boolean) => void
onContextMenu: (e: React.MouseEvent, row: TableRowType) => void
onSelectRow: (rowId: string) => void
+ onSelectCell: (rowId: string, columnName: string) => void
}) {
return (
onContextMenu(e, row)}
>
- |
+ |
onSelectRow(row.id)} />
|
- {columns.map((column) => (
-
-
- onDoubleClick(row.id, column.name)}
- onSave={(value) => onSave(row.id, column.name, value)}
- onCancel={onCancel}
- onBooleanToggle={() =>
- onBooleanToggle(row.id, column.name, Boolean(row.data[column.name]))
- }
- />
-
- |
- ))}
+ {columns.map((column) => {
+ const isColumnSelected = selectedColumnName === column.name
+ return (
+ onSelectCell(row.id, column.name)}
+ onDoubleClick={() => onDoubleClick(row.id, column.name)}
+ >
+ {isColumnSelected && }
+
+ onSave(row.id, column.name, value)}
+ onCancel={onCancel}
+ />
+
+ |
+ )
+ })}
)
})
@@ -437,20 +594,14 @@ function CellContent({
value,
column,
isEditing,
- onCellClick,
- onDoubleClick,
onSave,
onCancel,
- onBooleanToggle,
}: {
value: unknown
column: ColumnDefinition
isEditing: boolean
- onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
- onDoubleClick: () => void
onSave: (value: unknown) => void
onCancel: () => void
- onBooleanToggle: () => void
}) {
if (isEditing) {
return
@@ -461,75 +612,25 @@ function CellContent({
if (column.type === 'boolean') {
const boolValue = Boolean(value)
return (
-
- )
- }
-
- if (isNull) {
- return (
- {
- e.stopPropagation()
- onDoubleClick()
- }}
- >
- —
+
+ {isNull ? '' : boolValue ? 'true' : 'false'}
)
}
+ if (isNull) return null
+
if (column.type === 'json') {
- const jsonStr = JSON.stringify(value)
return (
-
+
+ {JSON.stringify(value)}
+
)
}
if (column.type === 'number') {
return (
- {
- e.stopPropagation()
- onDoubleClick()
- }}
- >
- {String(value)}
-
+ {String(value)}
)
}
@@ -543,66 +644,13 @@ function CellContent({
hour: '2-digit',
minute: '2-digit',
})
- return (
- {
- e.stopPropagation()
- onDoubleClick()
- }}
- >
- {formatted}
-
- )
+ return {formatted}
} catch {
- return (
- {
- e.stopPropagation()
- onDoubleClick()
- }}
- >
- {String(value)}
-
- )
+ return {String(value)}
}
}
- const strValue = String(value)
- if (strValue.length > STRING_TRUNCATE_LENGTH) {
- return (
-
- )
- }
-
- return (
- {
- e.stopPropagation()
- onDoubleClick()
- }}
- >
- {strValue}
-
- )
+ return {String(value)}
}
function InlineEditor({
@@ -631,10 +679,8 @@ function InlineEditor({
const handleSave = () => {
if (doneRef.current) return
doneRef.current = true
-
try {
- const cleaned = cleanCellValue(draft, column)
- onSave(cleaned)
+ onSave(cleanCellValue(draft, column))
} catch {
onCancel()
}
@@ -661,7 +707,14 @@ function InlineEditor({
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
- className='h-full w-full rounded-[2px] border-none bg-transparent px-[4px] py-[2px] text-[13px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--accent)] ring-inset'
+ className={cn(
+ 'h-full w-full min-w-0 border-none bg-transparent p-0 outline-none',
+ column.type === 'number'
+ ? 'font-mono text-[12px] text-[var(--text-secondary)]'
+ : column.type === 'date'
+ ? 'text-[12px] text-[var(--text-secondary)]'
+ : 'text-[13px] text-[var(--text-primary)]'
+ )}
/>
)
}
@@ -708,7 +761,7 @@ function TableSkeleton({ columns }: { columns: ColumnDefinition[] }) {
<>
{Array.from({ length: 25 }).map((_, rowIndex) => (
- |
+ |
|
{columns.map((col, colIndex) => {
@@ -724,14 +777,10 @@ function TableSkeleton({ columns }: { columns: ColumnDefinition[] }) {
: col.type === 'date'
? 100
: 120
- const variation = ((rowIndex + colIndex) % 3) * 20
- const width = baseWidth + variation
+ const width = baseWidth + ((rowIndex + colIndex) % 3) * 20
return (
-
+ |
|
)
@@ -742,64 +791,81 @@ function TableSkeleton({ columns }: { columns: ColumnDefinition[] }) {
)
}
-const PLACEHOLDER_ROW_COUNT = 50
-
function PlaceholderRows({
columns,
editingEmptyCell,
+ pendingPlaceholders,
+ selectedCell,
onDoubleClick,
onSave,
onCancel,
+ onSelectCell,
}: {
columns: ColumnDefinition[]
editingEmptyCell: { rowIndex: number; columnName: string } | null
+ pendingPlaceholders: Record
+ selectedCell: SelectedCell | null
onDoubleClick: (rowIndex: number, columnName: string) => void
- onSave: (columnName: string, value: unknown) => void
+ onSave: (rowIndex: number, columnName: string, value: unknown) => void
onCancel: () => void
+ onSelectCell: (index: number, columnName: string) => void
}) {
return (
<>
- {Array.from({ length: PLACEHOLDER_ROW_COUNT }).map((_, i) => (
-
- |
- {columns.map((col) => {
- const isEditing =
- editingEmptyCell?.rowIndex === i && editingEmptyCell.columnName === col.name
+ {Array.from({ length: PLACEHOLDER_ROW_COUNT }).map((_, i) => {
+ const pending = pendingPlaceholders[i]
+ return (
+
+ |
+ {columns.map((col) => {
+ const isEditing =
+ editingEmptyCell?.rowIndex === i && editingEmptyCell.columnName === col.name
+ const pendingValue = pending?.data[col.name]
+ const hasPendingValue = pendingValue !== undefined && pendingValue !== null
+ const isColumnSelected =
+ selectedCell?.kind === 'placeholder' &&
+ selectedCell.index === i &&
+ selectedCell.columnName === col.name
- return (
- onDoubleClick(i, col.name)}
- >
- {isEditing ? (
- onSave(col.name, value)}
- onCancel={onCancel}
- />
- ) : (
-
- )}
- |
- )
- })}
-
- ))}
+ return (
+ onSelectCell(i, col.name)}
+ onDoubleClick={() => onDoubleClick(i, col.name)}
+ >
+ {isColumnSelected && }
+ {isEditing ? (
+ onSave(i, col.name, value)}
+ onCancel={onCancel}
+ />
+ ) : hasPendingValue ? (
+
+ {}}
+ onCancel={() => {}}
+ />
+
+ ) : (
+
+ )}
+ |
+ )
+ })}
+
+ )
+ })}
>
)
}
-const COLUMN_TYPE_ICONS: Record = {
- string: TypeText,
- number: TypeNumber,
- boolean: TypeBoolean,
- date: CalendarIcon,
- json: TypeJson,
-}
-
function ColumnTypeIcon({ type }: { type: string }) {
const Icon = COLUMN_TYPE_ICONS[type] ?? TypeText
- return
+ return
}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx
index 69b92be4f4..8ea13d7f0c 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx
@@ -1,5 +1,10 @@
+import type { Metadata } from 'next'
import { Table } from './components'
+export const metadata: Metadata = {
+ title: 'Table',
+}
+
export default function TablePage() {
return
}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/create-modal/create-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/create-modal/create-modal.tsx
deleted file mode 100644
index 3174057867..0000000000
--- a/apps/sim/app/workspace/[workspaceId]/tables/components/create-modal/create-modal.tsx
+++ /dev/null
@@ -1,330 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { Plus, Trash2 } from 'lucide-react'
-import { nanoid } from 'nanoid'
-import { useParams } from 'next/navigation'
-import {
- Button,
- Checkbox,
- Combobox,
- Input,
- Label,
- Modal,
- ModalBody,
- ModalContent,
- ModalFooter,
- ModalHeader,
- Textarea,
-} from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
-import type { ColumnDefinition } from '@/lib/table'
-import { useCreateTable } from '@/hooks/queries/tables'
-
-const logger = createLogger('CreateModal')
-
-interface CreateModalProps {
- isOpen: boolean
- onClose: () => void
-}
-
-const COLUMN_TYPE_OPTIONS: Array<{ value: ColumnDefinition['type']; label: string }> = [
- { value: 'string', label: 'String' },
- { value: 'number', label: 'Number' },
- { value: 'boolean', label: 'Boolean' },
- { value: 'date', label: 'Date' },
- { value: 'json', label: 'JSON' },
-]
-
-interface ColumnWithId extends ColumnDefinition {
- id: string
-}
-
-function createEmptyColumn(): ColumnWithId {
- return { id: nanoid(), name: '', type: 'string', required: true, unique: false }
-}
-
-export function CreateModal({ isOpen, onClose }: CreateModalProps) {
- const params = useParams()
- const workspaceId = params.workspaceId as string
-
- const [tableName, setTableName] = useState('')
- const [description, setDescription] = useState('')
- const [columns, setColumns] = useState([createEmptyColumn()])
- const [error, setError] = useState(null)
-
- const createTable = useCreateTable(workspaceId)
-
- const handleAddColumn = () => {
- setColumns([...columns, createEmptyColumn()])
- }
-
- const handleRemoveColumn = (columnId: string) => {
- if (columns.length > 1) {
- setColumns(columns.filter((col) => col.id !== columnId))
- }
- }
-
- const handleColumnChange = (
- columnId: string,
- field: keyof ColumnDefinition,
- value: string | boolean
- ) => {
- setColumns(columns.map((col) => (col.id === columnId ? { ...col, [field]: value } : col)))
- }
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault()
- setError(null)
-
- if (!tableName.trim()) {
- setError('Table name is required')
- return
- }
-
- // Validate column names
- const validColumns = columns.filter((col) => col.name.trim())
- if (validColumns.length === 0) {
- setError('At least one column is required')
- return
- }
-
- // Check for duplicate column names
- const columnNames = validColumns.map((col) => col.name.toLowerCase())
- const uniqueNames = new Set(columnNames)
- if (uniqueNames.size !== columnNames.length) {
- setError('Duplicate column names found')
- return
- }
-
- // Strip internal IDs before sending to API
- const columnsForApi = validColumns.map(({ id: _id, ...col }) => col)
-
- try {
- await createTable.mutateAsync({
- name: tableName,
- description: description || undefined,
- schema: {
- columns: columnsForApi,
- },
- })
-
- // Reset form
- resetForm()
- onClose()
- } catch (err) {
- logger.error('Failed to create table:', err)
- setError(err instanceof Error ? err.message : 'Failed to create table')
- }
- }
-
- const resetForm = () => {
- setTableName('')
- setDescription('')
- setColumns([createEmptyColumn()])
- setError(null)
- }
-
- const handleClose = () => {
- resetForm()
- onClose()
- }
-
- return (
-
-
- Create Table
-
-
-
-
- )
-}
-
-interface ColumnRowProps {
- index: number
- column: ColumnWithId
- isRemovable: boolean
- onChange: (columnId: string, field: keyof ColumnDefinition, value: string | boolean) => void
- onRemove: (columnId: string) => void
-}
-
-function ColumnRow({ index, column, isRemovable, onChange, onRemove }: ColumnRowProps) {
- return (
-
-
-
- Column {index + 1}
-
-
-
-
-
-
-
- ) =>
- onChange(column.id, 'name', e.target.value)
- }
- placeholder='column_name'
- className='h-[36px]'
- />
-
-
-
-
- onChange(column.id, 'type', value as ColumnDefinition['type'])}
- placeholder='Type'
- editable={false}
- filterOptions={false}
- className='h-[36px]'
- />
-
-
-
- Required
- onChange(column.id, 'required', checked === true)}
- />
-
-
-
- Unique
- onChange(column.id, 'unique', checked === true)}
- />
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/create-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/components/create-modal/index.ts
deleted file mode 100644
index 3b8971fcd7..0000000000
--- a/apps/sim/app/workspace/[workspaceId]/tables/components/create-modal/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { CreateModal } from './create-modal'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/components/index.ts
index aab9fd532c..5319fd1c9c 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/components/index.ts
@@ -1,4 +1,3 @@
-export * from './create-modal'
export * from './empty-state'
export * from './error-state'
export * from './loading-state'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/page.tsx
index 007b782ffd..947d5c9403 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/page.tsx
@@ -1,9 +1,14 @@
+import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { Tables } from './tables'
+export const metadata: Metadata = {
+ title: 'Tables',
+}
+
interface TablesPageProps {
params: Promise<{
workspaceId: string
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
index d4553d6c39..b8f16a1a24 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
@@ -6,14 +6,15 @@ import { useParams, useRouter } from 'next/navigation'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons'
import type { TableDefinition } from '@/lib/table'
+import { generateUniqueTableName } from '@/lib/table/constants'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SchemaModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
-import { CreateModal, TablesListContextMenu } from '@/app/workspace/[workspaceId]/tables/components'
+import { TablesListContextMenu } from '@/app/workspace/[workspaceId]/tables/components'
import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
-import { useDeleteTable, useTablesList } from '@/hooks/queries/tables'
+import { useCreateTable, useDeleteTable, useTablesList } from '@/hooks/queries/tables'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
const logger = createLogger('Tables')
@@ -40,8 +41,8 @@ export function Tables() {
logger.error('Failed to load tables:', error)
}
const deleteTable = useDeleteTable(workspaceId)
+ const createTable = useCreateTable(workspaceId)
- const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isSchemaModalOpen, setIsSchemaModalOpen] = useState(false)
const [activeTable, setActiveTable] = useState(null)
@@ -100,10 +101,6 @@ export function Tables() {
[filteredTables, members]
)
- const handleSort = useCallback(() => {}, [])
-
- const handleFilter = useCallback(() => {}, [])
-
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
@@ -147,6 +144,23 @@ export function Tables() {
}
}
+ const handleCreateTable = useCallback(async () => {
+ const existingNames = tables.map((t) => t.name)
+ const name = generateUniqueTableName(existingNames)
+ try {
+ const result = await createTable.mutateAsync({
+ name,
+ schema: { columns: [{ name: 'name', type: 'string' }] },
+ })
+ const tableId = result?.data?.table?.id
+ if (tableId) {
+ router.push(`/workspace/${workspaceId}/tables/${tableId}`)
+ }
+ } catch (err) {
+ logger.error('Failed to create table:', err)
+ }
+ }, [tables, createTable, router, workspaceId])
+
return (
<>
setIsCreateModalOpen(true),
- disabled: userPermissions.canEdit !== true,
+ onClick: handleCreateTable,
+ disabled: userPermissions.canEdit !== true || createTable.isPending,
}}
search={{
value: searchTerm,
@@ -163,8 +177,8 @@ export function Tables() {
placeholder: 'Search tables...',
}}
defaultSort='created'
- onSort={handleSort}
- onFilter={handleFilter}
+ onSort={() => {}}
+ onFilter={() => {}}
columns={COLUMNS}
rows={rows}
onRowClick={handleRowClick}
@@ -178,8 +192,8 @@ export function Tables() {
position={listContextMenuPosition}
menuRef={listMenuRef}
onClose={closeListContextMenu}
- onCreateTable={() => setIsCreateModalOpen(true)}
- disableCreate={userPermissions.canEdit !== true}
+ onCreateTable={handleCreateTable}
+ disableCreate={userPermissions.canEdit !== true || createTable.isPending}
/>
)}
-
- setIsCreateModalOpen(false)} />
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/task/[taskId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/task/[taskId]/page.tsx
index 4ec8b80425..32e81af5e5 100644
--- a/apps/sim/app/workspace/[workspaceId]/task/[taskId]/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/task/[taskId]/page.tsx
@@ -1,5 +1,10 @@
+import type { Metadata } from 'next'
import { Home } from '@/app/workspace/[workspaceId]/home/home'
+export const metadata: Metadata = {
+ title: 'Task',
+}
+
interface TaskPageProps {
params: Promise<{
workspaceId: string
diff --git a/apps/sim/app/workspace/[workspaceId]/templates/page.tsx b/apps/sim/app/workspace/[workspaceId]/templates/page.tsx
index 8e5194cee0..deadab4605 100644
--- a/apps/sim/app/workspace/[workspaceId]/templates/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/templates/page.tsx
@@ -1,6 +1,7 @@
import { db } from '@sim/db'
import { settings, templateCreators, templateStars, templates, user } from '@sim/db/schema'
import { and, desc, eq, sql } from 'drizzle-orm'
+import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
@@ -8,6 +9,10 @@ import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
+export const metadata: Metadata = {
+ title: 'Templates',
+}
+
interface TemplatesPageProps {
params: Promise<{
workspaceId: string
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/page.tsx
index f361b9a97c..61abd393c3 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/page.tsx
@@ -1,3 +1,8 @@
+import type { Metadata } from 'next'
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
+export const metadata: Metadata = {
+ title: 'Workflow',
+}
+
export default Workflow
diff --git a/apps/sim/components/emcn/components/dropdown-menu/dropdown-menu.tsx b/apps/sim/components/emcn/components/dropdown-menu/dropdown-menu.tsx
new file mode 100644
index 0000000000..f27ee83ce5
--- /dev/null
+++ b/apps/sim/components/emcn/components/dropdown-menu/dropdown-menu.tsx
@@ -0,0 +1,218 @@
+/**
+ * Dropdown menu component built on Radix UI primitives with EMCN styling.
+ * Provides accessible, animated dropdown menus with consistent design tokens.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ *
+ * Actions
+ *
+ * Edit
+ * Delete
+ *
+ *
+ * ```
+ */
+
+'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 ANIMATION_CLASSES =
+ '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 data-[state=closed]:animate-out data-[state=open]:animate-in'
+
+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,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+))
+DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 6, ...props }, ref) => (
+
+
+
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts
index b696b8d250..dcaefa1082 100644
--- a/apps/sim/components/emcn/components/index.ts
+++ b/apps/sim/components/emcn/components/index.ts
@@ -37,6 +37,23 @@ export {
type ComboboxOptionGroup,
} from './combobox/combobox'
export { DatePicker, type DatePickerProps, datePickerVariants } from './date-picker/date-picker'
+export {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from './dropdown-menu/dropdown-menu'
export { Input, type InputProps, inputVariants } from './input/input'
export { Label } from './label/label'
export {
diff --git a/apps/sim/components/ui/dropdown-menu.tsx b/apps/sim/components/ui/dropdown-menu.tsx
index 4fe1f06edc..ec6e7f314b 100644
--- a/apps/sim/components/ui/dropdown-menu.tsx
+++ b/apps/sim/components/ui/dropdown-menu.tsx
@@ -45,7 +45,7 @@ const DropdownMenuSubContent = React.forwardRef<
,
React.ComponentPropsWithoutRef
->(({ className, sideOffset = 4, avoidCollisions = false, sticky = 'always', ...props }, ref) => (
+>(({ className, sideOffset = 4, ...props }, ref) => (
= {}): Metad
manifest: '/manifest.webmanifest',
icons: {
icon: [
+ ...(brand.faviconUrl ? [] : [{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' }]),
{ url: '/favicon/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
{ url: '/favicon/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
{
@@ -98,10 +99,10 @@ export function generateBrandedMetadata(override: Partial = {}): Metad
sizes: '512x512',
type: 'image/png',
},
- { url: brand.faviconUrl || '/sim.png', sizes: 'any', type: 'image/png' },
+ ...(brand.faviconUrl ? [{ url: brand.faviconUrl, sizes: 'any', type: 'image/png' }] : []),
],
apple: '/favicon/apple-touch-icon.png',
- shortcut: brand.faviconUrl || '/favicon/favicon.ico',
+ shortcut: brand.faviconUrl || '/icon.svg',
},
appleWebApp: {
capable: true,
diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts
index 63a77a048c..615a7b9eea 100644
--- a/apps/sim/hooks/queries/tables.ts
+++ b/apps/sim/hooks/queries/tables.ts
@@ -229,6 +229,39 @@ export function useCreateTable(workspaceId: string) {
})
}
+/**
+ * Add a column to an existing table.
+ */
+export function useAddTableColumn({ workspaceId, tableId }: RowMutationContext) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (column: {
+ name: string
+ type: string
+ required?: boolean
+ unique?: boolean
+ }) => {
+ const res = await fetch(`/api/table/${tableId}/columns`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workspaceId, column }),
+ })
+
+ if (!res.ok) {
+ const error = await res.json()
+ throw new Error(error.error || 'Failed to add column')
+ }
+
+ return res.json()
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) })
+ queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) })
+ },
+ })
+}
+
/**
* Delete a table from a workspace.
*/
diff --git a/apps/sim/lib/branding/defaults.ts b/apps/sim/lib/branding/defaults.ts
index 8ce6d1491b..c68a326753 100644
--- a/apps/sim/lib/branding/defaults.ts
+++ b/apps/sim/lib/branding/defaults.ts
@@ -6,7 +6,7 @@ import type { BrandConfig } from './types'
export const defaultBrandConfig: BrandConfig = {
name: 'Sim',
logoUrl: undefined,
- faviconUrl: '/favicon/favicon.ico',
+ faviconUrl: undefined,
customCssUrl: undefined,
supportEmail: 'help@sim.ai',
documentationUrl: undefined,
diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts
index b6529495b0..743da2950a 100644
--- a/apps/sim/lib/table/constants.ts
+++ b/apps/sim/lib/table/constants.ts
@@ -57,3 +57,221 @@ export const COLUMN_TYPES = ['string', 'number', 'boolean', 'date', 'json'] as c
export const NAME_PATTERN = /^[a-z_][a-z0-9_]*$/i
export const USER_TABLE_ROWS_SQL_NAME = 'user_table_rows'
+
+const TABLE_NAME_ADJECTIVES = [
+ 'Radiant',
+ 'Luminous',
+ 'Blazing',
+ 'Glowing',
+ 'Bright',
+ 'Gleaming',
+ 'Shining',
+ 'Lustrous',
+ 'Vivid',
+ 'Dazzling',
+ 'Stellar',
+ 'Cosmic',
+ 'Astral',
+ 'Galactic',
+ 'Nebular',
+ 'Orbital',
+ 'Lunar',
+ 'Solar',
+ 'Starlit',
+ 'Celestial',
+ 'Infinite',
+ 'Vast',
+ 'Boundless',
+ 'Immense',
+ 'Colossal',
+ 'Titanic',
+ 'Grand',
+ 'Supreme',
+ 'Eternal',
+ 'Ancient',
+ 'Timeless',
+ 'Primal',
+ 'Nascent',
+ 'Elder',
+ 'Swift',
+ 'Drifting',
+ 'Surging',
+ 'Pulsing',
+ 'Soaring',
+ 'Rising',
+ 'Spiraling',
+ 'Crimson',
+ 'Azure',
+ 'Violet',
+ 'Indigo',
+ 'Amber',
+ 'Sapphire',
+ 'Obsidian',
+ 'Silver',
+ 'Golden',
+ 'Scarlet',
+ 'Cobalt',
+ 'Emerald',
+ 'Magnetic',
+ 'Quantum',
+ 'Photonic',
+ 'Spectral',
+ 'Charged',
+ 'Atomic',
+ 'Electric',
+ 'Kinetic',
+ 'Ethereal',
+ 'Mystic',
+ 'Phantom',
+ 'Silent',
+ 'Distant',
+ 'Hidden',
+ 'Arcane',
+ 'Frozen',
+ 'Burning',
+ 'Molten',
+ 'Volatile',
+ 'Fiery',
+ 'Searing',
+ 'Frigid',
+ 'Mighty',
+ 'Fierce',
+ 'Serene',
+ 'Tranquil',
+ 'Harmonic',
+ 'Resonant',
+ 'Bold',
+ 'Noble',
+ 'Pure',
+ 'Rare',
+ 'Pristine',
+ 'Exotic',
+ 'Divine',
+] as const
+
+const TABLE_NAME_NOUNS = [
+ 'Star',
+ 'Pulsar',
+ 'Quasar',
+ 'Magnetar',
+ 'Nova',
+ 'Supernova',
+ 'Neutron',
+ 'Protostar',
+ 'Blazar',
+ 'Cepheid',
+ 'Galaxy',
+ 'Nebula',
+ 'Cluster',
+ 'Void',
+ 'Filament',
+ 'Halo',
+ 'Spiral',
+ 'Remnant',
+ 'Cloud',
+ 'Planet',
+ 'Moon',
+ 'World',
+ 'Exoplanet',
+ 'Titan',
+ 'Europa',
+ 'Triton',
+ 'Enceladus',
+ 'Comet',
+ 'Meteor',
+ 'Asteroid',
+ 'Fireball',
+ 'Shard',
+ 'Fragment',
+ 'Orion',
+ 'Andromeda',
+ 'Perseus',
+ 'Pegasus',
+ 'Phoenix',
+ 'Draco',
+ 'Cygnus',
+ 'Aquila',
+ 'Lyra',
+ 'Vega',
+ 'Hydra',
+ 'Sirius',
+ 'Polaris',
+ 'Altair',
+ 'Eclipse',
+ 'Aurora',
+ 'Corona',
+ 'Flare',
+ 'Vortex',
+ 'Pulse',
+ 'Wave',
+ 'Ripple',
+ 'Shimmer',
+ 'Spark',
+ 'Horizon',
+ 'Zenith',
+ 'Apex',
+ 'Meridian',
+ 'Equinox',
+ 'Solstice',
+ 'Transit',
+ 'Orbit',
+ 'Cosmos',
+ 'Dimension',
+ 'Realm',
+ 'Expanse',
+ 'Infinity',
+ 'Continuum',
+ 'Abyss',
+ 'Ether',
+ 'Photon',
+ 'Neutrino',
+ 'Tachyon',
+ 'Graviton',
+ 'Sector',
+ 'Quadrant',
+ 'Belt',
+ 'Ring',
+ 'Field',
+ 'Stream',
+ 'Frontier',
+ 'Beacon',
+ 'Signal',
+ 'Probe',
+ 'Voyager',
+ 'Pioneer',
+ 'Sentinel',
+ 'Gateway',
+ 'Portal',
+ 'Nexus',
+ 'Conduit',
+ 'Rift',
+ 'Core',
+ 'Matrix',
+ 'Lattice',
+ 'Array',
+ 'Reactor',
+ 'Engine',
+ 'Forge',
+ 'Crucible',
+] as const
+
+/**
+ * Generates a unique space-themed table name that doesn't collide with existing names.
+ * Uses lowercase with underscores to satisfy NAME_PATTERN validation.
+ */
+export function generateUniqueTableName(existingNames: string[]): string {
+ const taken = new Set(existingNames.map((n) => n.toLowerCase()))
+ const maxAttempts = 50
+
+ for (let i = 0; i < maxAttempts; i++) {
+ const adj = TABLE_NAME_ADJECTIVES[Math.floor(Math.random() * TABLE_NAME_ADJECTIVES.length)]
+ const noun = TABLE_NAME_NOUNS[Math.floor(Math.random() * TABLE_NAME_NOUNS.length)]
+ const name = `${adj.toLowerCase()}_${noun.toLowerCase()}`
+ if (!taken.has(name)) return name
+ }
+
+ const adj = TABLE_NAME_ADJECTIVES[Math.floor(Math.random() * TABLE_NAME_ADJECTIVES.length)]
+ const noun = TABLE_NAME_NOUNS[Math.floor(Math.random() * TABLE_NAME_NOUNS.length)]
+ const suffix = Math.floor(Math.random() * 900) + 100
+ return `${adj.toLowerCase()}_${noun.toLowerCase()}_${suffix}`
+}
diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts
index 7420ed625b..45b2fe2d53 100644
--- a/apps/sim/lib/table/service.ts
+++ b/apps/sim/lib/table/service.ts
@@ -213,6 +213,63 @@ export async function createTable(
}
}
+/**
+ * Adds a column to an existing table's schema.
+ *
+ * @param tableId - Table ID to update
+ * @param column - Column definition to add
+ * @param requestId - Request ID for logging
+ * @returns Updated table definition
+ * @throws Error if table not found or column name already exists
+ */
+export async function addTableColumn(
+ tableId: string,
+ column: { name: string; type: string; required?: boolean; unique?: boolean },
+ requestId: string
+): Promise {
+ const table = await getTableById(tableId)
+ if (!table) {
+ throw new Error('Table not found')
+ }
+
+ const schema = table.schema
+ if (schema.columns.some((c) => c.name.toLowerCase() === column.name.toLowerCase())) {
+ throw new Error(`Column "${column.name}" already exists`)
+ }
+
+ if (schema.columns.length >= TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) {
+ throw new Error(
+ `Table has reached maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})`
+ )
+ }
+
+ const newColumn = {
+ name: column.name,
+ type: column.type as TableSchema['columns'][number]['type'],
+ required: column.required ?? false,
+ unique: column.unique ?? false,
+ }
+
+ const updatedSchema: TableSchema = {
+ columns: [...schema.columns, newColumn],
+ }
+
+ const now = new Date()
+
+ await db
+ .update(userTableDefinitions)
+ .set({ schema: updatedSchema, updatedAt: now })
+ .where(eq(userTableDefinitions.id, tableId))
+
+ logger.info(`[${requestId}] Added column "${column.name}" to table ${tableId}`)
+
+ return {
+ ...table,
+ schema: updatedSchema,
+ updatedAt: now,
+ }
+}
+
/**
* Deletes a table (hard delete).
*
diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts
index f453dc52d9..f1058f5834 100644
--- a/apps/sim/next.config.ts
+++ b/apps/sim/next.config.ts
@@ -369,6 +369,10 @@ const nextConfig: NextConfig = {
},
async rewrites() {
return [
+ {
+ source: '/favicon.ico',
+ destination: '/icon.svg',
+ },
{
source: '/r/:shortCode',
destination: 'https://go.trybeluga.ai/:shortCode',
diff --git a/apps/sim/package.json b/apps/sim/package.json
index 66ba0ad4dd..36b6ab8b4d 100644
--- a/apps/sim/package.json
+++ b/apps/sim/package.json
@@ -61,7 +61,7 @@
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.5",
- "@radix-ui/react-dropdown-menu": "^2.1.4",
+ "@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-progress": "^1.1.2",
diff --git a/apps/sim/public/icon.svg b/apps/sim/public/icon.svg
new file mode 100644
index 0000000000..757af0c47a
--- /dev/null
+++ b/apps/sim/public/icon.svg
@@ -0,0 +1,11 @@
+