Compare commits

..

17 Commits

Author SHA1 Message Date
waleed
95b17ff820 fix(copilot): abort in-progress tools when stream ends
Calls abortAllInProgressTools in the stream_end handler so tools
stuck in generating/executing state are resolved when the stream
closes, regardless of whether a done event was received.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 15:58:55 -07:00
Waleed
25a03f1f3c feat(auth): migrate to better-auth admin plugin with unified Admin tab (#3612)
* feat(auth): migrate to better-auth admin plugin

* feat(settings): add unified Admin tab with user management

Consolidate superuser features into a single Admin settings tab:
- Super admin mode toggle (moved from General)
- Workflow import (moved from Debug)
- User management via better-auth admin (list, set role, ban/unban)

Replace Debug tab with Admin tab gated by requiresAdminRole.
Add React Query hooks for admin user operations.

* fix(db): backfill existing super users to admin role in migration

Add UPDATE statement to promote is_super_user=true rows to role='admin'
before dropping the is_super_user column, preventing silent demotion.

* fix(admin): resolve type errors in admin tab

- Fix cn import path to @/lib/core/utils/cn
- Use valid Badge variants (blue/gray/red/green instead of secondary/destructive)
- Type setRole param as 'user' | 'admin' union

* improvement(auth): remove /api/user/super-user route, use session role

Include user.role in customSession so it's available client-side.
Replace all useSuperUserStatus() calls with session.user.role === 'admin'.
Delete the now-redundant /api/user/super-user endpoint.

* chore(auth): remove redundant role override in customSession

The admin plugin already includes role on the user object.
No need to manually spread it in customSession.

* improvement(queries): clean up admin-users hooks per React Query best practices

- Remove unsafe unknown/Record casting, use better-auth typed response
- Add placeholderData: keepPreviousData for paginated variable-key query
- Remove nullable types where defaults are always applied

* fix(admin): address review feedback on admin tab

- Fix superUserModeEnabled default to false (matches sidebar behavior)
- Reset banReason when switching ban target to prevent state bleed
- Guard admin section render with session role check for direct URL access

* fix(settings): align superUserModeEnabled default to false everywhere

Three places defaulted to true while admin tab and sidebar used false.
Align all to false so new admins see consistent behavior.

* fix(admin): fix stale pendingUserId, add isPending guard and error feedback

- Only read mutation.variables when mutation isPending (prevents stale ID)
- Add isPending guard to super user mode toggle (prevents concurrent mutations)
- Show inline error message when setRole/ban/unban mutations fail

* fix(admin): concurrent pending users Set, session loading guard, domain blocking

- Replace pendingUserId scalar with pendingUserIds Set (useMemo) so concurrent
  mutations across different users each disable their own row correctly
- Add sessionLoading guard to admin section redirect to prevent flash on direct
  /settings/admin navigation before session resolves
- Add BLOCKED_SIGNUP_DOMAINS env var and before-hook for email domain denylist,
  parsed once at module init as a Set for O(1) per-request lookups
- Add trailing newline to migration file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(admin): close OAuth domain bypass, fix stale errors, deduplicate icon

- Add databaseHooks.user.create.before to enforce BLOCKED_SIGNUP_DOMAINS at
  the model level, covering all signup vectors (email, OAuth, social) not just
  /sign-up paths
- Call .reset() on each mutation before firing to clear stale error state from
  previous operations
- Change Admin nav icon from ShieldCheck to Lock to avoid duplicate with
  Access Control tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 15:04:54 -07:00
Siddharth Ganesan
35c42ba227 fix(mothership): fix tool call scheduling (#3635)
* Fix mothership tool scheduling

* Fix
2026-03-17 13:30:09 -07:00
Theodore Li
3bd2750d22 fix(ui): ensure new resource tab button is always visible (#3633)
* fix(ui): ensure new resource tab button is always visible

* Fix vertical scroll input scrolling tabs horizontally

* Fix incorrect tool tip on file edit button

* Fix lint, attach scroll listener to tabs themselves

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-17 14:33:58 -04:00
Theodore Li
70d8df5a19 fix(ui): add back file split view (#3632)
* fix(ui): add back file split view

* Open md in split view

* Fix lint

* Default to preview

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-17 13:48:56 -04:00
Siddharth Ganesan
101fcec135 fix(mothership): stream management (#3623)
* Fix

* Fix

* Fix

* Fix

* Fix lint

* Fix
2026-03-17 10:42:13 -07:00
Waleed
1873f2d775 improvement(mothership): tool display titles, html sanitization, and ui fixes (#3631)
* improvement(mothership): tool display titles, html sanitization, and ui fixes

- Use TOOL_UI_METADATA as fallback for tool display titles (fast_edit shows "Editing workflow" instead of "Fast Edit")
- Harden HTML-to-text extraction with replaceUntilStable to prevent nested tag injection
- Decode HTML entities in a single pass to avoid double-unescaping
- Fix Google Drive/Docs query escaping for backslashes in folder IDs
- Replace regex with indexOf for email sender/display name parsing
- Update embedded workflow run tooltip to "Run workflow"

* fix(security): decode entities before tag stripping and cap loop iterations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:31:58 -07:00
Waleed
3e3c160789 fix(embedded): autolayout viewport calculation for resource view (#3629)
* fix(embedded): autolayout viewport calculation for resource view

* fix(embedded): default mothership view to 60% width, remove minimum
2026-03-17 09:30:10 -07:00
Siddharth Ganesan
8fa4f3fdbb fix(mothership): thinking and subagent text (#3613)
* Thinking v0

* Change

* Fix

* improvement(ui/ux): mothership chat experience

* user input animation

* improvement(landing): desktop complete

* auth and 404

* mobile friendliness and home templates

* improvement(home): templates

* fix: feature flags

* address comments

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-03-17 07:55:50 -07:00
Theodore Li
b3d9e54bb2 fix(ui) fix task switch causing duplicate text renderings (#3624)
* Fix task switch causing duplicate text renderings

* Fix lint

* Pass expectedGen

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-17 05:33:16 -04:00
Waleed
b930ee311f improvement(tables): tables multi-select, keyboard shortcuts, and docs (#3615) 2026-03-17 01:54:37 -07:00
Vikhyath Mondreti
e804ea356c fix(embedded): block layout should not be dependent on viewport (#3621)
* fix(embedded): block layout should not be dependent on viewport

* address comments
2026-03-16 23:16:33 -07:00
Theodore Li
2a7b07e3b4 Fix row_count context (#3622)
Co-authored-by: Theodore Li <teddy@zenobiapay.com>
2026-03-17 01:35:09 -04:00
Theodore Li
974cc66b0e fix(ui) add embedded workflow notifications, switch tab on workflow run (#3618)
* Include notification view in embedded workflow view

* fix(ui) fix workflow not showing up when mothership calls run

* Wire up fix in mothership

* Refresh events after workflow run

* Fix so run workflow switches tabs as well

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-16 23:22:31 -04:00
Theodore Li
c867801988 fix(ui) Live update resources in resource main view (#3617)
* Live update resources in resource main view

* Stop updating on read tool calls

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-16 21:46:59 -04:00
Theodore Li
c090c821be fix(mothership): add promptForToolApproval to prevent tool hang in mothership chat (#3616)
Tools with requiresConfirmation (e.g. user_table) blocked indefinitely
in mothership because the frontend has no approval UI. Added a new
promptForToolApproval orchestrator option so mothership auto-executes
these tools while copilot continues to prompt for user approval.

Made-with: Cursor

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-16 20:44:29 -04:00
Theodore Li
36e502a068 fix(workflow) fix mothership double-running workflows (#3614)
* fix(workflow) fix mothership double-running workflows

* Remove interactive override

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-16 20:11:26 -04:00
182 changed files with 18724 additions and 1829 deletions

View File

@@ -1,9 +1,21 @@
import type { ReactNode } from 'react'
import type { Viewport } from 'next'
export default function RootLayout({ children }: { children: ReactNode }) {
return children
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#0c0c0c' },
],
}
export const metadata = {
metadataBase: new URL('https://docs.sim.ai'),
title: {
@@ -12,6 +24,9 @@ export const metadata = {
},
description:
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
applicationName: 'Sim Docs',
generator: 'Next.js',
referrer: 'origin-when-cross-origin' as const,
keywords: [
'AI agents',
'agentic workforce',
@@ -37,17 +52,28 @@ export const metadata = {
manifest: '/favicon/site.webmanifest',
icons: {
icon: [
{ 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' },
{ url: '/favicon/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' },
{ url: '/favicon/android-chrome-512x512.png', sizes: '512x512', type: 'image/png' },
],
apple: '/favicon/apple-touch-icon.png',
shortcut: '/favicon/favicon.ico',
shortcut: '/icon.svg',
},
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'Sim Docs',
},
formatDetection: {
telephone: false,
},
other: {
'apple-mobile-web-app-capable': 'yes',
'mobile-web-app-capable': 'yes',
'msapplication-TileColor': '#33C482',
},
openGraph: {
type: 'website',
locale: 'en_US',

View File

@@ -13,6 +13,7 @@
"mailer",
"skills",
"knowledgebase",
"tables",
"variables",
"credentials",
"execution",

View File

@@ -0,0 +1,158 @@
---
title: Tables
description: Store, query, and manage structured data directly within your workspace
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Image } from '@/components/ui/image'
import { FAQ } from '@/components/ui/faq'
Tables let you store and manage structured data directly in your workspace. Use them to maintain reference data, collect workflow outputs, or build lightweight databases — all without leaving Sim.
<Image src="/static/tables/tables-overview.png" alt="Tables view showing structured data with typed columns for name, title, company, role, and more" width={800} height={500} />
Each table has a schema of typed columns, supports filtering and sorting, and is fully accessible through the [Tables API](/docs/en/api-reference/(generated)/tables).
## Creating a Table
1. Open the **Tables** section from your workspace sidebar
2. Click **New table**
3. Name your table and start adding columns
Tables start with a single text column. Add more columns by clicking **New column** in the column header area.
## Column Types
Each column has a type that determines how values are stored and validated.
| Type | Description | Example Values |
|------|-------------|----------------|
| **Text** | Free-form string | `"Acme Corp"`, `"hello@example.com"` |
| **Number** | Numeric value | `42`, `3.14`, `-100` |
| **Boolean** | True or false | `true`, `false` |
| **Date** | Date value | `2026-03-16` |
| **JSON** | Structured object or array | `{"key": "value"}`, `[1, 2, 3]` |
<Callout type="info">
Column types are enforced on input. For example, typing into a Number column is restricted to digits, dots, and minus signs. Non-numeric values entered via paste are coerced to `0`.
</Callout>
## Working with Rows
### Adding Rows
- Click **New row** below the last row to append a new row
- Press **Shift + Enter** while a cell is selected to insert a row below
- Paste tabular data (from a spreadsheet or TSV) to bulk-create rows
### Editing Cells
Click a cell to select it, then press **Enter**, **F2**, or start typing to edit. Press **Escape** to cancel, or **Tab** to save and move to the next cell.
### Selecting Rows
Click a row's checkbox to select it. Selecting additional checkboxes adds to the selection without clearing previous selections.
| Action | Behavior |
|--------|----------|
| Click checkbox | Toggle that row's selection |
| Shift + click checkbox | Select range from last clicked to current |
| Click header checkbox | Select all / deselect all |
| Shift + Space | Toggle row selection from keyboard |
### Deleting Rows
Right-click a selected row (or group of selected rows) and choose **Delete row** from the context menu.
## Filtering and Sorting
Use the toolbar above the table to filter and sort your data.
- **Filter**: Set conditions on any column (e.g., "Name contains Acme"). Multiple filters are combined with AND logic.
- **Sort**: Order rows by any column, ascending or descending.
Filters and sorts are applied in real time and do not modify the underlying data.
## Keyboard Shortcuts
All shortcuts work when the table is focused and no cell is being edited.
<Callout type="info">
**Mod** refers to `Cmd` on macOS and `Ctrl` on Windows/Linux.
</Callout>
### Navigation
| Shortcut | Action |
|----------|--------|
| Arrow keys | Move one cell |
| `Mod` + Arrow keys | Jump to edge of table |
| `Tab` / `Shift` + `Tab` | Move to next / previous cell |
| `Escape` | Clear selection |
### Selection
| Shortcut | Action |
|----------|--------|
| `Shift` + Arrow keys | Extend selection by one cell |
| `Mod` + `Shift` + Arrow keys | Extend selection to edge |
| `Mod` + `A` | Select all rows |
| `Shift` + `Space` | Toggle current row selection |
### Editing
| Shortcut | Action |
|----------|--------|
| `Enter` or `F2` | Start editing selected cell |
| `Escape` | Cancel editing |
| Type any character | Start editing with that character |
| `Shift` + `Enter` | Insert new row below |
| `Space` | Expand row details |
### Clipboard
| Shortcut | Action |
|----------|--------|
| `Mod` + `C` | Copy selected cells |
| `Mod` + `X` | Cut selected cells |
| `Mod` + `V` | Paste |
| `Delete` / `Backspace` | Clear selected cells (all columns when using checkbox selection) |
### History
| Shortcut | Action |
|----------|--------|
| `Mod` + `Z` | Undo |
| `Mod` + `Shift` + `Z` | Redo |
| `Mod` + `Y` | Redo (alternative) |
## Using Tables in Workflows
Tables can be read from and written to within your workflows using the **Table** block. Common patterns include:
- **Lookup**: Query a table for reference data (e.g., pricing rules, customer metadata)
- **Write-back**: Store workflow outputs in a table for later review or reporting
- **Iteration**: Process each row in a table as part of a batch workflow
## API Access
Tables are fully accessible through the REST API. You can create, read, update, and delete both tables and rows programmatically.
See the [Tables API Reference](/docs/en/api-reference/(generated)/tables) for endpoints, parameters, and examples.
## Best Practices
- **Use typed columns** to enforce data integrity — prefer Number and Boolean over storing everything as Text
- **Name columns descriptively** so they are self-documenting when referenced in workflows
- **Use JSON columns sparingly** — they are flexible but harder to filter and sort against
- **Leverage the API** for bulk imports rather than manually entering large datasets
<FAQ items={[
{ question: "Is there a row limit per table?", answer: "Tables are designed for working datasets. For very large datasets (100k+ rows), consider paginating API reads or splitting data across multiple tables." },
{ question: "Can I import data from a spreadsheet?", answer: "Yes. Copy rows from any spreadsheet application and paste them directly into the table. Column values will be validated against the column types." },
{ question: "Do tables support formulas?", answer: "Tables store raw data and do not support computed formulas. Use workflow logic (Function block or Agent block) to derive computed values and write them back to the table." },
{ question: "Can multiple workflows write to the same table?", answer: "Yes. Table writes are atomic at the row level, so multiple workflows can safely write to the same table concurrently." },
{ question: "How do I reference a table from a workflow?", answer: "Use the Table block in your workflow. Select the target table from the dropdown, choose an operation (read, write, update), and configure the parameters." },
{ question: "Are tables shared across workspace members?", answer: "Yes. Tables are workspace-scoped and accessible to all members with appropriate permissions." },
{ question: "Can I undo changes?", answer: "In the table editor, Cmd/Ctrl+Z undoes recent cell edits, row insertions, and row deletions. API-driven changes are not covered by the editor's undo history." },
]} />

View File

@@ -1,21 +1,42 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"name": "Sim Documentation — Build AI Agents & Run Your Agentic Workforce",
"short_name": "Sim Docs",
"description": "Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.",
"start_url": "/",
"scope": "/",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"src": "/favicon/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"src": "/favicon/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/favicon/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/favicon/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/favicon/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"theme_color": "#33C482",
"background_color": "#ffffff",
"display": "standalone"
"display": "standalone",
"categories": ["productivity", "developer", "business"],
"lang": "en-US",
"dir": "ltr"
}

14
apps/docs/public/icon.svg Normal file
View File

@@ -0,0 +1,14 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="#0B0B0B"/>
<g transform="translate(2.75,2.75) scale(0.0473)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M107.822 93.7612C107.822 97.3481 106.403 100.792 103.884 103.328L103.523 103.692C101.006 106.236 97.5855 107.658 94.0236 107.658H13.4455C6.02456 107.658 0 113.718 0 121.191V208.332C0 215.806 6.02456 221.866 13.4455 221.866H99.9622C107.383 221.866 113.4 215.806 113.4 208.332V126.745C113.4 123.419 114.71 120.228 117.047 117.874C119.377 115.527 122.546 114.207 125.849 114.207H207.777C215.198 114.207 221.214 108.148 221.214 100.674V13.5333C221.214 6.05956 215.198 0 207.777 0H121.26C113.839 0 107.822 6.05956 107.822 13.5333V93.7612ZM134.078 18.55H194.952C199.289 18.55 202.796 22.0893 202.796 26.4503V87.7574C202.796 92.1178 199.289 95.6577 194.952 95.6577H134.078C129.748 95.6577 126.233 92.1178 126.233 87.7574V26.4503C126.233 22.0893 129.748 18.55 134.078 18.55Z" fill="#33C482"/>
<path d="M207.878 129.57H143.554C135.756 129.57 129.434 135.937 129.434 143.791V207.784C129.434 215.638 135.756 222.005 143.554 222.005H207.878C215.677 222.005 221.999 215.638 221.999 207.784V143.791C221.999 135.937 215.677 129.57 207.878 129.57Z" fill="#33C482"/>
<path d="M207.878 129.266H143.554C135.756 129.266 129.434 135.632 129.434 143.487V207.479C129.434 215.333 135.756 221.699 143.554 221.699H207.878C215.677 221.699 221.999 215.333 221.999 207.479V143.487C221.999 135.632 215.677 129.266 207.878 129.266Z" fill="url(#paint0_linear)" fill-opacity="0.2"/>
</g>
<defs>
<linearGradient id="paint0_linear" x1="129.434" y1="129.266" x2="185.629" y2="185.33" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

View File

@@ -55,7 +55,7 @@ export const BrandedButton = forwardRef<HTMLButtonElement, BrandedButtonProps>(
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(
'group inline-flex h-[30px] items-center justify-center gap-[7px] rounded-[5px] border px-[9px] text-[13.5px] transition-colors disabled:cursor-not-allowed disabled:opacity-50',
'group inline-flex h-[32px] items-center justify-center gap-[8px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px] transition-colors disabled:cursor-not-allowed disabled:opacity-50',
!hasCustomColor &&
'border-[#FFFFFF] bg-[#FFFFFF] text-black hover:border-[#E0E0E0] hover:bg-[#E0E0E0]',
fullWidth && 'w-full',

View File

@@ -28,8 +28,12 @@ export function StatusPageLayout({
<div className='w-full max-w-lg px-4'>
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>{title}</h1>
<p className='font-[380] text-[#999] text-[16px]'>{description}</p>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
{title}
</h1>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
{description}
</p>
</div>
{children && <div className='mt-8 w-full max-w-[410px] space-y-3'>{children}</div>}
</div>

View File

@@ -383,8 +383,12 @@ export default function LoginPage({
return (
<>
<div className='space-y-1 text-center'>
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>Sign in</h1>
<p className='font-[380] text-[#999] text-[16px]'>Enter your details</p>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
Sign in
</h1>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
Enter your details
</p>
</div>
{/* SSO Login Button (primary top-only when it is the only method) */}

View File

@@ -127,10 +127,12 @@ export default function OAuthConsentPage() {
return (
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1 className={'font-[500] text-[#ECECEC] text-[32px] tracking-tight'}>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
Authorize Application
</h1>
<p className={'font-[380] text-[#999] text-[16px]'}>Loading application details...</p>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
Loading application details...
</p>
</div>
</div>
)
@@ -140,10 +142,12 @@ export default function OAuthConsentPage() {
return (
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1 className={'font-[500] text-[#ECECEC] text-[32px] tracking-tight'}>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
Authorization Error
</h1>
<p className={'font-[380] text-[#999] text-[16px]'}>{error}</p>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
{error}
</p>
</div>
<div className='mt-8 w-full max-w-[410px] space-y-3'>
<BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
@@ -181,10 +185,10 @@ export default function OAuthConsentPage() {
</div>
<div className='space-y-1 text-center'>
<h1 className={'font-[500] text-[#ECECEC] text-[32px] tracking-tight'}>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
Authorize Application
</h1>
<p className={'font-[380] text-[#999] text-[16px]'}>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
<span className='font-medium text-[#ECECEC]'>{clientName}</span> is requesting access to
your account
</p>

View File

@@ -74,10 +74,12 @@ function ResetPasswordContent() {
return (
<>
<div className='space-y-1 text-center'>
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
Reset your password
</h1>
<p className='font-[380] text-[#999] text-[16px]'>Enter a new password for your account</p>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
Enter a new password for your account
</p>
</div>
<div className='mt-8'>

View File

@@ -341,8 +341,12 @@ function SignupFormContent({
return (
<>
<div className='space-y-1 text-center'>
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>Create an account</h1>
<p className='font-[380] text-[#999] text-[16px]'>Create an account or log in</p>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
Create an account
</h1>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
Create an account or log in
</p>
</div>
{/* SSO Login Button (primary top-only when it is the only method) */}

View File

@@ -59,10 +59,10 @@ function VerificationForm({
return (
<>
<div className='space-y-1 text-center'>
<h1 className='font-[500] text-[#ECECEC] text-[32px] tracking-tight'>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
{isVerified ? 'Email Verified!' : 'Verify Your Email'}
</h1>
<p className='font-[380] text-[#999] text-[16px]'>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
{isVerified
? 'Your email has been verified. Redirecting to dashboard...'
: !isEmailVerificationEnabled

View File

@@ -222,34 +222,15 @@ export default function Collaboration() {
<style dangerouslySetInnerHTML={{ __html: CURSOR_KEYFRAMES }} />
<DotGrid
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
className='overflow-hidden border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
cols={120}
rows={1}
gap={6}
/>
<div className='relative overflow-hidden'>
<Link
href='/studio/multiplayer'
target='_blank'
rel='noopener noreferrer'
className='absolute bottom-10 left-4 z-20 flex cursor-none items-center gap-[14px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] px-[12px] py-[10px] transition-colors hover:border-[#3d3d3d] hover:bg-[#232323] sm:left-8 md:left-[80px]'
>
<div className='relative h-7 w-11 shrink-0'>
<Image src='/landing/multiplayer-cursors.svg' alt='' fill className='object-contain' />
</div>
<div className='flex flex-col gap-[2px]'>
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[12px] uppercase leading-[100%] tracking-[0.08em]'>
Blog
</span>
<span className='font-[430] font-season text-[#F6F6F0] text-[14px] leading-[125%] tracking-[0.02em]'>
How we built realtime collaboration
</span>
</div>
</Link>
<div className='grid grid-cols-[auto_1fr]'>
<div className='flex flex-col items-start gap-3 px-4 pt-[100px] pb-8 sm:gap-4 sm:px-8 md:gap-[20px] md:px-[80px]'>
<div className='grid grid-cols-1 md:grid-cols-[auto_1fr]'>
<div className='flex flex-col items-start gap-3 px-4 pt-[60px] pb-8 sm:gap-4 sm:px-8 md:gap-[20px] md:px-[80px] md:pt-[100px]'>
<Badge
variant='blue'
size='md'
@@ -268,8 +249,9 @@ export default function Collaboration() {
collaboration
</h2>
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
Grab your team. Build agents together <br /> in real-time inside your workspace.
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[15px] leading-[150%] tracking-[0.02em] md:text-[18px]'>
Grab your team. Build agents together <br className='hidden md:block' />
in real-time inside your workspace.
</p>
<Link
@@ -298,14 +280,14 @@ export default function Collaboration() {
</Link>
</div>
<figure className='pointer-events-none relative h-[600px] w-full'>
<div className='-left-[18%] absolute inset-y-0 min-w-full'>
<figure className='pointer-events-none relative h-[220px] w-full md:h-[600px]'>
<div className='md:-left-[18%] -top-[10%] absolute inset-y-0 left-[7%] min-w-full md:top-0'>
<Image
src='/landing/collaboration-visual.svg'
alt='Collaboration visual showing team workflows with real-time editing, shared cursors, and version control interface'
width={876}
height={480}
className='h-full w-auto min-w-[100vw] object-left'
className='h-full w-auto object-left md:min-w-[100vw]'
priority
/>
</div>
@@ -319,10 +301,29 @@ export default function Collaboration() {
</figcaption>
</figure>
</div>
<Link
href='/studio/multiplayer'
target='_blank'
rel='noopener noreferrer'
className='relative mx-4 mb-6 flex cursor-none items-center gap-[14px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] px-[12px] py-[10px] transition-colors hover:border-[#3d3d3d] hover:bg-[#232323] sm:mx-8 md:absolute md:bottom-10 md:left-[80px] md:z-20 md:mx-0 md:mb-0'
>
<div className='relative h-7 w-11 shrink-0'>
<Image src='/landing/multiplayer-cursors.svg' alt='' fill className='object-contain' />
</div>
<div className='flex flex-col gap-[2px]'>
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[12px] uppercase leading-[100%] tracking-[0.08em]'>
Blog
</span>
<span className='font-[430] font-season text-[#F6F6F0] text-[14px] leading-[125%] tracking-[0.02em]'>
How we built realtime collaboration
</span>
</div>
</Link>
</div>
<DotGrid
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
className='overflow-hidden border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
cols={120}
rows={1}
gap={6}

View File

@@ -77,6 +77,7 @@ const FEATURE_TABS = [
},
{
label: 'Knowledge Base',
mobileLabel: 'Knowledge',
color: '#8B5CF6',
title: 'Your context engine',
description:
@@ -97,6 +98,7 @@ const FEATURE_TABS = [
},
{
label: 'Logs',
hideOnMobile: true,
color: '#FF6B35',
title: 'Full visibility, every run',
description:
@@ -150,7 +152,7 @@ function DotGrid({
return (
<div
aria-hidden='true'
className={`shrink-0 bg-[#F6F6F6] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
className={`h-full shrink-0 bg-[#F6F6F6] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
style={{
width: width ? `${width}px` : undefined,
display: 'grid',
@@ -192,8 +194,11 @@ export default function Features() {
/>
</div>
<div className='relative z-10 pt-[100px]'>
<div ref={sectionRef} className='flex flex-col items-start gap-[20px] px-[80px]'>
<div className='relative z-10 pt-[60px] lg:pt-[100px]'>
<div
ref={sectionRef}
className='flex flex-col items-start gap-[20px] px-[24px] lg:px-[80px]'
>
<Badge
variant='blue'
size='md'
@@ -211,7 +216,7 @@ export default function Features() {
</Badge>
<h2
id='features-heading'
className='max-w-[900px] font-[430] font-season text-[#1C1C1C] text-[40px] leading-[110%] tracking-[-0.02em]'
className='max-w-[900px] font-[430] font-season text-[#1C1C1C] text-[28px] leading-[110%] tracking-[-0.02em] md:text-[40px]'
>
{HEADING_LETTERS.map((char, i) => (
<ScrollLetter key={i} scrollYProgress={scrollYProgress} charIndex={i}>
@@ -225,18 +230,25 @@ export default function Features() {
</h2>
</div>
<div className='relative mt-[73px] pb-[80px]'>
<div className='relative mt-[40px] pb-[40px] lg:mt-[73px] lg:pb-[80px]'>
<div
aria-hidden='true'
className='absolute top-0 bottom-0 left-[80px] z-20 w-px bg-[#E9E9E9]'
className='absolute top-0 bottom-0 left-[80px] z-20 hidden w-px bg-[#E9E9E9] lg:block'
/>
<div
aria-hidden='true'
className='absolute top-0 right-[80px] bottom-0 z-20 w-px bg-[#E9E9E9]'
className='absolute top-0 right-[80px] bottom-0 z-20 hidden w-px bg-[#E9E9E9] lg:block'
/>
<div className='flex h-[68px] overflow-hidden border border-[#E9E9E9]'>
<DotGrid cols={10} rows={8} width={80} />
<div className='flex h-[68px] border border-[#E9E9E9] lg:overflow-hidden'>
<div className='h-full shrink-0'>
<div className='h-full lg:hidden'>
<DotGrid cols={3} rows={8} width={24} />
</div>
<div className='hidden h-full lg:block'>
<DotGrid cols={10} rows={8} width={80} />
</div>
</div>
<div role='tablist' aria-label='Feature categories' className='flex flex-1'>
{FEATURE_TABS.map((tab, index) => (
@@ -246,10 +258,17 @@ export default function Features() {
role='tab'
aria-selected={index === activeTab}
onClick={() => setActiveTab(index)}
className='relative flex h-full flex-1 items-center justify-center border-[#E9E9E9] border-l font-medium font-season text-[#212121] text-[14px] uppercase'
className={`relative h-full flex-1 items-center justify-center whitespace-nowrap px-[12px] font-medium font-season text-[#212121] text-[12px] uppercase lg:px-0 lg:text-[14px]${tab.hideOnMobile ? ' hidden lg:flex' : ' flex'}${index > 0 ? ' border-[#E9E9E9] border-l' : ''}`}
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
>
{tab.label}
{tab.mobileLabel ? (
<>
<span className='lg:hidden'>{tab.mobileLabel}</span>
<span className='hidden lg:inline'>{tab.label}</span>
</>
) : (
tab.label
)}
{index === activeTab && (
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
{tab.segments.map(([opacity, width], i) => (
@@ -269,16 +288,23 @@ export default function Features() {
))}
</div>
<DotGrid cols={10} rows={8} width={80} borderLeft />
<div className='h-full shrink-0'>
<div className='h-full lg:hidden'>
<DotGrid cols={3} rows={8} width={24} />
</div>
<div className='hidden h-full lg:block'>
<DotGrid cols={10} rows={8} width={80} />
</div>
</div>
</div>
<div className='mt-[60px] grid grid-cols-[1fr_2.8fr] gap-[60px] px-[120px]'>
<div className='flex h-[560px] flex-col items-start justify-between pt-[20px]'>
<div className='mt-[32px] flex flex-col gap-[24px] px-[24px] lg:mt-[60px] lg:grid lg:grid-cols-[1fr_2.8fr] lg:gap-[60px] lg:px-[120px]'>
<div className='flex flex-col items-start justify-between gap-[24px] pt-[20px] lg:h-[560px] lg:gap-0'>
<div className='flex flex-col items-start gap-[16px]'>
<h3 className='font-[430] font-season text-[#1C1C1C] text-[28px] leading-[120%] tracking-[-0.02em]'>
<h3 className='font-[430] font-season text-[#1C1C1C] text-[24px] leading-[120%] tracking-[-0.02em] lg:text-[28px]'>
{FEATURE_TABS[activeTab].title}
</h3>
<p className='font-[430] font-season text-[#1C1C1C]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
<p className='font-[430] font-season text-[#1C1C1C]/50 text-[16px] leading-[150%] tracking-[0.02em] lg:text-[18px]'>
{FEATURE_TABS[activeTab].description}
</p>
</div>
@@ -307,10 +333,10 @@ export default function Features() {
</Link>
</div>
<FeaturesPreview />
<FeaturesPreview activeTab={activeTab} />
</div>
<div aria-hidden='true' className='mt-[60px] h-px bg-[#E9E9E9]' />
<div aria-hidden='true' className='mt-[60px] hidden h-px bg-[#E9E9E9] lg:block' />
</div>
</div>
</section>

View File

@@ -0,0 +1,100 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { ArrowUp } from 'lucide-react'
import Link from 'next/link'
import { useLandingSubmit } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
import { useAnimatedPlaceholder } from '@/app/workspace/[workspaceId]/home/hooks/use-animated-placeholder'
const MAX_HEIGHT = 120
const CTA_BUTTON =
'inline-flex items-center h-[32px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px]'
export function FooterCTA() {
const landingSubmit = useLandingSubmit()
const [inputValue, setInputValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const animatedPlaceholder = useAnimatedPlaceholder()
const isEmpty = inputValue.trim().length === 0
const handleSubmit = useCallback(() => {
if (isEmpty) return
landingSubmit(inputValue)
}, [isEmpty, inputValue, landingSubmit])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
},
[handleSubmit]
)
const handleInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = `${Math.min(target.scrollHeight, MAX_HEIGHT)}px`
}, [])
return (
<div className='flex flex-col items-center px-4 pt-[120px] pb-[100px] sm:px-8 md:px-[80px]'>
<h2 className='text-center font-[430] font-season text-[#1C1C1C] text-[28px] leading-[100%] tracking-[-0.02em] sm:text-[32px] md:text-[36px]'>
What should we get done?
</h2>
<div className='mt-8 w-full max-w-[42rem]'>
<div
className='cursor-text rounded-[20px] border border-[#E5E5E5] bg-white px-[10px] py-[8px] shadow-sm'
onClick={() => textareaRef.current?.focus()}
>
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder={animatedPlaceholder}
rows={2}
className='m-0 box-border min-h-[48px] w-full resize-none border-0 bg-transparent px-[4px] py-[4px] font-body text-[#1C1C1C] text-[15px] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[#999] focus-visible:ring-0'
style={{ caretColor: '#1C1C1C', maxHeight: `${MAX_HEIGHT}px` }}
/>
<div className='flex items-center justify-end'>
<button
type='button'
onClick={handleSubmit}
disabled={isEmpty}
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
style={{
background: isEmpty ? '#C0C0C0' : '#1C1C1C',
cursor: isEmpty ? 'not-allowed' : 'pointer',
}}
>
<ArrowUp size={16} strokeWidth={2.25} color='#FFFFFF' />
</button>
</div>
</div>
</div>
<div className='mt-8 flex gap-[8px]'>
<a
href='https://docs.sim.ai'
target='_blank'
rel='noopener noreferrer'
className={`${CTA_BUTTON} border-[#D4D4D4] text-[#1C1C1C] transition-colors hover:bg-[#E8E8E8]`}
>
Docs
</a>
<Link
href='/signup'
className={`${CTA_BUTTON} gap-[8px] border-[#1C1C1C] bg-[#1C1C1C] text-white transition-colors hover:border-[#333] hover:bg-[#333]`}
>
Get started
</Link>
</div>
</div>
)
}

View File

@@ -1,188 +1,165 @@
import Image from 'next/image'
import Link from 'next/link'
import { FOOTER_BLOCKS, FOOTER_TOOLS } from '@/app/(landing)/components/footer/consts'
import { FooterCTA } from '@/app/(home)/components/footer/footer-cta'
const LINK_CLASS = 'text-[14px] text-[#999] transition-colors hover:text-[#ECECEC]'
interface FooterLink {
interface FooterItem {
label: string
href: string
external?: boolean
}
const FOOTER_LINKS: FooterLink[] = [
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
const PRODUCT_LINKS: FooterItem[] = [
{ label: 'Pricing', href: '#pricing' },
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
{ label: 'Sim Studio', href: '/studio' },
{ label: 'Changelog', href: '/changelog' },
{ label: 'Self Hosting', href: 'https://docs.sim.ai/self-hosting', external: true },
{ label: 'MCP', href: 'https://docs.sim.ai/mcp', external: true },
{ label: 'Status', href: 'https://status.sim.ai', external: true },
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
{ label: 'SOC2', href: 'https://trust.delve.co/sim-studio', external: true },
{ label: 'Privacy Policy', href: '/privacy', external: true },
{ label: 'Terms of Service', href: '/terms', external: true },
]
export default function Footer() {
const RESOURCES_LINKS: FooterItem[] = [
{ label: 'Blog', href: '/blog' },
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
{ label: 'Changelog', href: '/changelog' },
]
const BLOCK_LINKS: FooterItem[] = [
{ label: 'Agent', href: 'https://docs.sim.ai/blocks/agent', external: true },
{ label: 'Router', href: 'https://docs.sim.ai/blocks/router', external: true },
{ label: 'Function', href: 'https://docs.sim.ai/blocks/function', external: true },
{ label: 'Condition', href: 'https://docs.sim.ai/blocks/condition', external: true },
{ label: 'API', href: 'https://docs.sim.ai/blocks/api', external: true },
{ label: 'Workflow', href: 'https://docs.sim.ai/blocks/workflow', external: true },
{ label: 'Parallel', href: 'https://docs.sim.ai/blocks/parallel', external: true },
{ label: 'Guardrails', href: 'https://docs.sim.ai/blocks/guardrails', external: true },
{ label: 'Evaluator', href: 'https://docs.sim.ai/blocks/evaluator', external: true },
{ label: 'Loop', href: 'https://docs.sim.ai/blocks/loop', external: true },
]
const INTEGRATION_LINKS: FooterItem[] = [
{ label: 'Confluence', href: 'https://docs.sim.ai/tools/confluence', external: true },
{ label: 'Slack', href: 'https://docs.sim.ai/tools/slack', external: true },
{ label: 'GitHub', href: 'https://docs.sim.ai/tools/github', external: true },
{ label: 'Gmail', href: 'https://docs.sim.ai/tools/gmail', external: true },
{ label: 'HubSpot', href: 'https://docs.sim.ai/tools/hubspot', external: true },
{ label: 'Salesforce', href: 'https://docs.sim.ai/tools/salesforce', external: true },
{ label: 'Notion', href: 'https://docs.sim.ai/tools/notion', external: true },
{ label: 'Google Drive', href: 'https://docs.sim.ai/tools/google_drive', external: true },
{ label: 'Google Sheets', href: 'https://docs.sim.ai/tools/google_sheets', external: true },
{ label: 'Supabase', href: 'https://docs.sim.ai/tools/supabase', external: true },
{ label: 'Stripe', href: 'https://docs.sim.ai/tools/stripe', external: true },
{ label: 'Jira', href: 'https://docs.sim.ai/tools/jira', external: true },
{ label: 'Linear', href: 'https://docs.sim.ai/tools/linear', external: true },
{ label: 'Airtable', href: 'https://docs.sim.ai/tools/airtable', external: true },
{ label: 'Firecrawl', href: 'https://docs.sim.ai/tools/firecrawl', external: true },
{ label: 'Pinecone', href: 'https://docs.sim.ai/tools/pinecone', external: true },
{ label: 'Discord', href: 'https://docs.sim.ai/tools/discord', external: true },
{ label: 'Microsoft Teams', href: 'https://docs.sim.ai/tools/microsoft_teams', external: true },
{ label: 'Outlook', href: 'https://docs.sim.ai/tools/outlook', external: true },
{ label: 'Telegram', href: 'https://docs.sim.ai/tools/telegram', external: true },
]
const SOCIAL_LINKS: FooterItem[] = [
{ label: 'X (Twitter)', href: 'https://x.com/simdotai', external: true },
{ label: 'LinkedIn', href: 'https://www.linkedin.com/company/simstudioai/', external: true },
{ label: 'Discord', href: 'https://discord.gg/Hr4UWYEcTT', external: true },
{ label: 'GitHub', href: 'https://github.com/simstudioai/sim', external: true },
]
const LEGAL_LINKS: FooterItem[] = [
{ label: 'Terms of Service', href: '/terms' },
{ label: 'Privacy Policy', href: '/privacy' },
]
function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) {
return (
<div>
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>{title}</h3>
<div className='flex flex-col gap-[10px]'>
{items.map(({ label, href, external }) =>
external ? (
<a
key={label}
href={href}
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
{label}
</a>
) : (
<Link key={label} href={href} className={LINK_CLASS}>
{label}
</Link>
)
)}
</div>
</div>
)
}
interface FooterProps {
hideCTA?: boolean
}
export default function Footer({ hideCTA }: FooterProps) {
return (
<footer
role='contentinfo'
className='relative w-full overflow-hidden bg-[#1C1C1C] font-[430] font-season text-[14px]'
className={`bg-[#F6F6F6] pb-[40px] font-[430] font-season text-[14px]${hideCTA ? ' pt-[40px]' : ''}`}
>
<div className='px-4 pt-[80px] pb-[40px] sm:px-8 sm:pb-[340px] md:px-[80px]'>
<nav aria-label='Footer navigation' className='flex justify-between'>
{/* Brand column */}
<div className='flex flex-col gap-[24px]'>
<Link href='/' aria-label='Sim home'>
<Image
src='/logo/sim-landing.svg'
alt='Sim'
width={71}
height={22}
className='h-[22px] w-auto'
/>
</Link>
</div>
{/* Community column */}
<div>
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>Community</h3>
<div className='flex flex-col gap-[12px]'>
<a
href='https://discord.gg/Hr4UWYEcTT'
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
Discord
</a>
<a
href='https://x.com/simdotai'
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
X (Twitter)
</a>
<a
href='https://www.linkedin.com/company/simstudioai/'
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
LinkedIn
</a>
<a
href='https://github.com/simstudioai/sim'
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
GitHub
</a>
{!hideCTA && <FooterCTA />}
<div className='px-4 sm:px-8 md:px-[80px]'>
<div className='relative overflow-hidden rounded-lg bg-[#1C1C1C] px-6 pt-[40px] pb-[32px] sm:px-10 sm:pt-[48px] sm:pb-[40px]'>
<nav
aria-label='Footer navigation'
className='relative z-[1] grid grid-cols-2 gap-x-8 gap-y-10 sm:grid-cols-3 lg:grid-cols-7'
>
<div className='col-span-2 flex flex-col gap-[24px] sm:col-span-1'>
<Link href='/' aria-label='Sim home'>
<Image
src='/logo/sim-landing.svg'
alt='Sim'
width={85}
height={26}
className='h-[26.4px] w-auto'
/>
</Link>
</div>
</div>
{/* Links column */}
<div>
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>More Sim</h3>
<div className='flex flex-col gap-[12px]'>
{FOOTER_LINKS.map(({ label, href, external }) =>
external ? (
<a
key={label}
href={href}
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
{label}
</a>
) : (
<Link key={label} href={href} className={LINK_CLASS}>
{label}
</Link>
)
)}
</div>
</div>
<FooterColumn title='Product' items={PRODUCT_LINKS} />
<FooterColumn title='Resources' items={RESOURCES_LINKS} />
<FooterColumn title='Blocks' items={BLOCK_LINKS} />
<FooterColumn title='Integrations' items={INTEGRATION_LINKS} />
<FooterColumn title='Socials' items={SOCIAL_LINKS} />
<FooterColumn title='Legal' items={LEGAL_LINKS} />
</nav>
{/* Blocks column */}
<div className='hidden sm:block'>
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>Blocks</h3>
<div className='flex flex-col gap-[12px]'>
{FOOTER_BLOCKS.map((block) => (
<a
key={block}
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replaceAll(' ', '-')}`}
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
{block}
</a>
))}
</div>
</div>
{/* Tools columns */}
<div className='hidden sm:block'>
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>Tools</h3>
<div className='flex gap-[80px]'>
{[0, 1, 2, 3].map((quarter) => {
const start = Math.ceil((FOOTER_TOOLS.length * quarter) / 4)
const end =
quarter === 3
? FOOTER_TOOLS.length
: Math.ceil((FOOTER_TOOLS.length * (quarter + 1)) / 4)
return (
<div key={quarter} className='flex flex-col gap-[12px]'>
{FOOTER_TOOLS.slice(start, end).map((tool) => (
<a
key={tool}
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
target='_blank'
rel='noopener noreferrer'
className={`whitespace-nowrap ${LINK_CLASS}`}
>
{tool}
</a>
))}
</div>
)
})}
</div>
</div>
</nav>
</div>
{/* Large SIM wordmark — half cut off */}
<div className='-translate-x-1/2 pointer-events-none absolute bottom-[-240px] left-1/2 hidden sm:block'>
<svg
xmlns='http://www.w3.org/2000/svg'
width='1128'
height='550'
viewBox='0 0 1128 550'
fill='none'
>
<path
d='M3 420.942H77.9115C77.9115 441.473 85.4027 457.843 100.385 470.051C115.367 481.704 135.621 487.53 161.147 487.53C188.892 487.53 210.255 482.258 225.238 471.715C240.22 460.617 247.711 445.913 247.711 427.601C247.711 414.283 243.549 403.185 235.226 394.307C227.457 385.428 213.03 378.215 191.943 372.666L120.361 356.019C84.2929 347.14 57.3802 333.545 39.6234 315.234C22.4215 296.922 13.8206 272.784 13.8206 242.819C13.8206 217.849 20.2019 196.208 32.9646 177.896C46.2822 159.584 64.3165 145.434 87.0674 135.446C110.373 125.458 137.008 120.464 166.973 120.464C196.938 120.464 222.74 125.735 244.382 136.278C266.578 146.821 283.779 161.526 295.987 180.393C308.75 199.259 315.409 221.733 315.964 247.813H241.052C240.497 226.727 233.561 210.357 220.243 198.705C206.926 187.052 188.337 181.225 164.476 181.225C140.06 181.225 121.194 186.497 107.876 197.04C94.5585 207.583 87.8997 222.01 87.8997 240.322C87.8997 267.512 107.876 286.101 147.829 296.09L219.411 313.569C253.815 321.337 279.618 334.1 296.82 351.857C314.022 369.059 322.622 392.642 322.622 422.607C322.622 448.132 315.686 470.606 301.814 490.027C287.941 508.894 268.797 523.599 244.382 534.142C220.521 544.13 192.221 549.124 159.482 549.124C111.76 549.124 73.7498 537.471 45.4499 514.165C17.15 490.86 3 459.785 3 420.942Z'
fill='#2A2A2A'
/>
<path
d='M377.713 539.136V132.117C408.911 143.439 422.667 143.439 455.954 132.117V539.136H377.713ZM416.001 105.211C402.129 105.211 389.921 100.217 379.378 90.2291C369.39 79.686 364.395 67.4782 364.395 53.6057C364.395 39.1783 369.39 26.9705 379.378 16.9823C389.921 6.9941 402.129 2 416.001 2C430.428 2 442.636 6.9941 452.625 16.9823C462.613 26.9705 467.607 39.1783 467.607 53.6057C467.607 67.4782 462.613 79.686 452.625 90.2291C442.636 100.217 430.428 105.211 416.001 105.211Z'
fill='#2A2A2A'
/>
<path
d='M593.961 539.136H515.72V132.117H585.637V200.792C593.961 178.041 610.053 158.752 632.249 143.769C655 128.232 682.467 120.464 714.651 120.464C750.72 120.464 780.685 130.174 804.545 149.596C822.01 163.812 835.016 181.446 843.562 202.5C851.434 181.446 864.509 163.812 882.786 149.596C907.757 130.174 938.554 120.464 975.177 120.464C1021.79 120.464 1058.41 134.059 1085.05 161.249C1111.68 188.439 1125 225.617 1125 272.784V539.136H1048.42V291.928C1048.42 259.744 1040.1 235.051 1023.45 217.849C1007.36 200.092 985.443 191.213 957.698 191.213C938.276 191.213 921.074 195.653 906.092 204.531C891.665 212.855 880.289 225.062 871.966 241.154C863.642 257.247 859.48 276.113 859.48 297.754V539.136H782.072V291.095C782.072 258.911 774.026 234.496 757.934 217.849C741.841 200.647 719.923 192.046 692.178 192.046C672.756 192.046 655.555 196.485 640.572 205.363C626.145 213.687 614.769 225.895 606.446 241.987C598.122 257.524 593.961 276.113 593.961 297.754V539.136Z'
fill='#2A2A2A'
/>
<path
d='M166.973 121.105C196.396 121.105 221.761 126.201 243.088 136.367L244.101 136.855L244.106 136.858C265.86 147.191 282.776 161.528 294.876 179.865L295.448 180.741L295.455 180.753C308.032 199.345 314.656 221.475 315.306 247.171H241.675C240.996 226.243 234.012 209.899 220.666 198.222C207.196 186.435 188.437 180.583 164.476 180.583C139.977 180.583 120.949 185.871 107.478 196.536C93.9928 207.212 87.2578 221.832 87.2578 240.322C87.2579 254.096 92.3262 265.711 102.444 275.127C112.542 284.524 127.641 291.704 147.673 296.712L147.677 296.713L219.259 314.192L219.27 314.195C253.065 321.827 278.469 334.271 295.552 351.48L296.358 352.304L296.365 352.311C313.42 369.365 321.98 392.77 321.98 422.606C321.98 448.005 315.082 470.343 301.297 489.646C287.502 508.408 268.456 523.046 244.134 533.55C220.369 543.498 192.157 548.482 159.481 548.482C111.864 548.482 74.0124 536.855 45.8584 513.67C17.8723 490.623 3.80059 459.948 3.64551 421.584H77.2734C77.4285 441.995 84.9939 458.338 99.9795 470.549L99.9854 470.553L99.9912 470.558C115.12 482.324 135.527 488.172 161.146 488.172C188.96 488.172 210.474 482.889 225.607 472.24L225.613 472.236L225.619 472.231C240.761 461.015 248.353 446.12 248.353 427.601C248.352 414.145 244.145 402.89 235.709 393.884C227.81 384.857 213.226 377.603 192.106 372.045L192.098 372.043L192.089 372.04L120.507 355.394C84.5136 346.533 57.7326 332.983 40.0908 314.794H40.0918C23.0227 296.624 14.4629 272.654 14.4629 242.819C14.4629 217.969 20.8095 196.463 33.4834 178.273C46.7277 160.063 64.6681 145.981 87.3252 136.034L87.3242 136.033C110.536 126.086 137.081 121.106 166.973 121.105ZM975.177 121.105C1021.66 121.105 1058.1 134.658 1084.59 161.698C1111.08 188.741 1124.36 225.743 1124.36 272.784V538.494H1049.07V291.928C1049.07 259.636 1040.71 234.76 1023.92 217.402H1023.91C1007.68 199.5 985.584 190.571 957.697 190.571C938.177 190.571 920.862 195.034 905.771 203.975C891.228 212.365 879.77 224.668 871.396 240.859C863.017 257.059 858.838 276.03 858.838 297.754V538.494H782.714V291.096C782.714 258.811 774.641 234.209 758.395 217.402C742.16 200.053 720.062 191.404 692.178 191.404C673.265 191.404 656.422 195.592 641.666 203.985L640.251 204.808C625.711 213.196 614.254 225.497 605.88 241.684C597.496 257.333 593.318 276.031 593.318 297.754V538.494H516.361V132.759H584.995V200.792L586.24 201.013C594.51 178.408 610.505 159.221 632.607 144.302L632.61 144.3C655.238 128.847 682.574 121.105 714.651 121.105C750.599 121.105 780.413 130.781 804.14 150.094C821.52 164.241 834.461 181.787 842.967 202.741L843.587 204.268L844.163 202.725C851.992 181.786 864.994 164.248 883.181 150.103C908.021 130.782 938.673 121.106 975.177 121.105ZM455.312 538.494H378.354V133.027C393.534 138.491 404.652 141.251 416.05 141.251C427.46 141.251 439.095 138.485 455.312 133.009V538.494ZM416.001 2.6416C430.262 2.6416 442.306 7.57157 452.171 17.4365C462.036 27.3014 466.965 39.3445 466.965 53.6055C466.965 67.3043 462.04 79.3548 452.16 89.7842C442.297 99.6427 430.258 104.569 416.001 104.569C402.303 104.569 390.254 99.6452 379.825 89.7676C369.957 79.3421 365.037 67.2967 365.037 53.6055C365.037 39.3444 369.966 27.3005 379.831 17.4355C390.258 7.56247 402.307 2.64163 416.001 2.6416Z'
stroke='#3D3D3D'
strokeWidth='1.28396'
/>
</svg>
{/* <svg
aria-hidden='true'
className='pointer-events-none absolute bottom-0 left-[-60px] hidden w-[85%] sm:block'
viewBox='0 0 1800 316'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M18.3562 305V48.95A30.594 30.594 0 0 1 48.95 18.356H917.05A30.594 30.594 0 0 1 947.644 48.95V273H1768C1777.11 273 1784.5 280.387 1784.5 289.5C1784.5 298.613 1777.11 306 1768 306H96.8603C78.635 306 63.8604 310 63.8604 305H18.3562'
stroke='#2A2A2A'
strokeWidth='2'
/>
<rect
x='58'
y='58'
width='849.288'
height='199.288'
rx='14'
stroke='#2A2A2A'
strokeWidth='2'
/>
</svg> */}
</div>
</div>
</footer>
)

View File

@@ -34,7 +34,7 @@ export default function Hero() {
<section
id='hero'
aria-labelledby='hero-heading'
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[100px] pb-[12px]'
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[60px] pb-[12px] lg:pt-[100px]'
>
<p className='sr-only'>
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect
@@ -61,11 +61,11 @@ export default function Hero() {
<div className='relative z-10 flex flex-col items-center gap-[12px]'>
<h1
id='hero-heading'
className='font-[430] font-season text-[72px] text-white leading-[100%] tracking-[-0.02em]'
className='font-[430] font-season text-[36px] text-white leading-[100%] tracking-[-0.02em] sm:text-[48px] lg:text-[72px]'
>
Build AI Agents
</h1>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[15px] leading-[125%] tracking-[0.02em] lg:text-[18px]'>
Sim is the AI Workspace for Agent Builders.
</p>

View File

@@ -14,6 +14,7 @@ import {
GmailIcon,
GoogleCalendarIcon,
GoogleSheetsIcon,
HubspotIcon,
JiraIcon,
LinearIcon,
LinkedInIcon,
@@ -22,6 +23,7 @@ import {
OpenAIIcon,
RedditIcon,
ReductoIcon,
SalesforceIcon,
ScheduleIcon,
SlackIcon,
StartIcon,
@@ -57,11 +59,13 @@ const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
google_calendar: GoogleCalendarIcon,
gmail: GmailIcon,
google_sheets: GoogleSheetsIcon,
hubspot: HubspotIcon,
linear: LinearIcon,
firecrawl: FirecrawlIcon,
reddit: RedditIcon,
notion: NotionIcon,
reducto: ReductoIcon,
salesforce: SalesforceIcon,
textract: TextractIcon,
linkedin: LinkedInIcon,
mothership: Blimp,

View File

@@ -112,10 +112,14 @@ const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
},
{
id: 'mothership-1',
name: 'Update Agent',
name: 'CRM Agent',
type: 'mothership',
bgColor: '#33C482',
rows: [{ title: 'Prompt', value: 'Audit CRM records, fix...' }],
tools: [
{ name: 'HubSpot', type: 'hubspot', bgColor: '#FF7A59' },
{ name: 'Salesforce', type: 'salesforce', bgColor: '#E0E0E0' },
],
position: { x: 420, y: 180 },
hideSourceHandle: true,
},

View File

@@ -95,7 +95,7 @@ export function LandingPreview() {
onSelectHome={handleSelectHome}
/>
</motion.div>
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
<div className='flex min-w-0 flex-1 flex-col py-[8px] pr-[8px] pl-[8px] lg:pl-0'>
<div className='flex flex-1 overflow-hidden rounded-[8px] border border-[#2c2c2c] bg-[#1b1b1b]'>
<div
className={

View File

@@ -0,0 +1,101 @@
import Link from 'next/link'
import { cn } from '@/lib/core/utils/cn'
const FEATURED_POST = {
title: 'Build with Sim for Enterprise',
slug: 'enterprise',
image: '/blog/thumbnails/enterprise.webp',
} as const
const POSTS = [
{ title: 'Introducing Sim v0.5', slug: 'v0-5', image: '/blog/thumbnails/v0-5.webp' },
{ title: '$7M Series A', slug: 'series-a', image: '/blog/thumbnails/series-a.webp' },
{
title: 'Realtime Collaboration',
slug: 'multiplayer',
image: '/blog/thumbnails/multiplayer.webp',
},
{ title: 'Inside the Executor', slug: 'executor', image: '/blog/thumbnails/executor.webp' },
{ title: 'Inside Sim Copilot', slug: 'copilot', image: '/blog/thumbnails/copilot.webp' },
] as const
function BlogCard({
slug,
image,
title,
imageHeight,
titleSize = '12px',
className,
}: {
slug: string
image: string
title: string
imageHeight: string
titleSize?: string
className?: string
}) {
return (
<Link
href={`/blog/${slug}`}
className={cn(
'group/card flex flex-col overflow-hidden rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] transition-colors hover:border-[#3D3D3D] hover:bg-[#2A2A2A]',
className
)}
prefetch={false}
>
<div className='w-full overflow-hidden bg-[#141414]' style={{ height: imageHeight }}>
<img
src={image}
alt={title}
decoding='async'
className='h-full w-full object-cover transition-transform duration-200 group-hover/card:scale-[1.02]'
/>
</div>
<div className='flex-shrink-0 px-[10px] py-[6px]'>
<span
className='font-[430] font-season text-[#cdcdcd] leading-[140%]'
style={{ fontSize: titleSize }}
>
{title}
</span>
</div>
</Link>
)
}
export function BlogDropdown() {
return (
<div className='w-[560px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] p-[16px] shadow-[0_16px_48px_rgba(0,0,0,0.4)]'>
<div className='grid grid-cols-3 gap-[8px]'>
<BlogCard
slug={FEATURED_POST.slug}
image={FEATURED_POST.image}
title={FEATURED_POST.title}
imageHeight='190px'
titleSize='13px'
className='col-span-2 row-span-2'
/>
{POSTS.slice(0, 2).map((post) => (
<BlogCard
key={post.slug}
slug={post.slug}
image={post.image}
title={post.title}
imageHeight='72px'
/>
))}
{POSTS.slice(2).map((post) => (
<BlogCard
key={post.slug}
slug={post.slug}
image={post.image}
title={post.title}
imageHeight='72px'
/>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,92 @@
import { AgentIcon, GithubOutlineIcon, McpIcon } from '@/components/icons'
const PREVIEW_CARDS = [
{
title: 'Introduction',
href: 'https://docs.sim.ai',
image: '/landing/docs-getting-started.svg',
},
{
title: 'Getting Started',
href: 'https://docs.sim.ai/getting-started',
image: '/landing/docs-intro.svg',
},
] as const
const RESOURCE_CARDS = [
{
title: 'Agent',
description: 'Build AI agents',
href: 'https://docs.sim.ai/blocks/agent',
icon: AgentIcon,
},
{
title: 'MCP',
description: 'Connect tools',
href: 'https://docs.sim.ai/mcp',
icon: McpIcon,
},
{
title: 'Self-hosting',
description: 'Host on your infra',
href: 'https://docs.sim.ai/self-hosting',
icon: GithubOutlineIcon,
},
] as const
export function DocsDropdown() {
return (
<div className='w-[480px] rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] p-[16px] shadow-[0_16px_48px_rgba(0,0,0,0.4)]'>
<div className='grid grid-cols-2 gap-[10px]'>
{PREVIEW_CARDS.map((card) => (
<a
key={card.title}
href={card.href}
target='_blank'
rel='noopener noreferrer'
className='group/card overflow-hidden rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] transition-colors hover:border-[#3D3D3D] hover:bg-[#2A2A2A]'
>
<div className='h-[120px] w-full overflow-hidden bg-[#141414]'>
<img
src={card.image}
alt={card.title}
decoding='async'
className='h-full w-full scale-[1.04] object-cover transition-transform duration-200 group-hover/card:scale-[1.06]'
/>
</div>
<div className='px-[10px] py-[8px]'>
<span className='font-[430] font-season text-[#cdcdcd] text-[13px]'>
{card.title}
</span>
</div>
</a>
))}
</div>
<div className='mt-[8px] grid grid-cols-3 gap-[8px]'>
{RESOURCE_CARDS.map((card) => {
const Icon = card.icon
return (
<a
key={card.title}
href={card.href}
target='_blank'
rel='noopener noreferrer'
className='flex flex-col gap-[4px] rounded-[5px] border border-[#2A2A2A] px-[10px] py-[8px] transition-colors hover:border-[#3D3D3D] hover:bg-[#232323]'
>
<div className='flex items-center gap-[6px]'>
<Icon className='h-[13px] w-[13px] flex-shrink-0 text-[#939393]' />
<span className='font-[430] font-season text-[#cdcdcd] text-[12px]'>
{card.title}
</span>
</div>
<span className='font-season text-[#939393] text-[11px] leading-[130%]'>
{card.description}
</span>
</a>
)
})}
</div>
</div>
)
}

View File

@@ -1,27 +1,33 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { ChevronDown } from '@/components/emcn'
import { GithubOutlineIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import { BlogDropdown } from '@/app/(home)/components/navbar/components/blog-dropdown'
import { DocsDropdown } from '@/app/(home)/components/navbar/components/docs-dropdown'
import { GitHubStars } from '@/app/(home)/components/navbar/components/github-stars'
import { getBrandConfig } from '@/ee/whitelabeling'
type DropdownId = 'docs' | 'blog' | null
interface NavLink {
label: string
href: string
external?: boolean
icon?: 'chevron'
dropdown?: 'docs' | 'blog'
}
const NAV_LINKS: NavLink[] = [
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Careers', href: '/careers' },
{ label: 'Docs', href: 'https://docs.sim.ai', external: true, icon: 'chevron', dropdown: 'docs' },
{ label: 'Blog', href: '/blog', icon: 'chevron', dropdown: 'blog' },
{ label: 'Pricing', href: '#pricing' },
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
]
/** Logo and nav edge: horizontal padding (px) for left/right symmetry. */
const LOGO_CELL = 'flex items-center px-[20px]'
/** Links: even spacing between items. */
const LOGO_CELL = 'flex items-center pl-[20px] lg:pl-[80px] pr-[20px]'
const LINK_CELL = 'flex items-center px-[14px]'
interface NavbarProps {
@@ -30,15 +36,58 @@ interface NavbarProps {
export default function Navbar({ logoOnly = false }: NavbarProps) {
const brand = getBrandConfig()
const [activeDropdown, setActiveDropdown] = useState<DropdownId>(null)
const [hoveredLink, setHoveredLink] = useState<string | null>(null)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const openDropdown = useCallback((id: DropdownId) => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
setActiveDropdown(id)
}, [])
const scheduleClose = useCallback(() => {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
closeTimerRef.current = setTimeout(() => {
setActiveDropdown(null)
closeTimerRef.current = null
}, 100)
}, [])
useEffect(() => {
return () => {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
}
}, [])
useEffect(() => {
document.body.style.overflow = mobileMenuOpen ? 'hidden' : ''
return () => {
document.body.style.overflow = ''
}
}, [mobileMenuOpen])
useEffect(() => {
const mq = window.matchMedia('(min-width: 1024px)')
const handler = () => {
if (mq.matches) setMobileMenuOpen(false)
}
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
const anyHighlighted = activeDropdown !== null || hoveredLink !== null
return (
<nav
aria-label='Primary navigation'
className='flex h-[52px] border-[#2A2A2A] border-b-[1px] bg-[#1C1C1C] font-[430] font-season text-[#ECECEC] text-[14px]'
className='relative flex h-[52px] border-[#2A2A2A] border-b-[1px] bg-[#1C1C1C] font-[430] font-season text-[#ECECEC] text-[14px]'
itemScope
itemType='https://schema.org/SiteNavigationElement'
>
{/* Logo */}
<Link href='/' className={LOGO_CELL} aria-label={`${brand.name} home`} itemProp='url'>
<span itemProp='name' className='sr-only'>
{brand.name}
@@ -67,37 +116,93 @@ export default function Navbar({ logoOnly = false }: NavbarProps) {
{!logoOnly && (
<>
{/* Links */}
<ul className='mt-[0.75px] flex'>
{NAV_LINKS.map(({ label, href, external, icon }) => (
<li key={label} className='flex'>
{external ? (
<a href={href} target='_blank' rel='noopener noreferrer' className={LINK_CELL}>
{label}
</a>
) : (
<Link
href={href}
className={icon ? `${LINK_CELL} gap-[8px]` : LINK_CELL}
aria-label={label}
<ul className='mt-[0.75px] hidden lg:flex'>
{NAV_LINKS.map(({ label, href, external, icon, dropdown }) => {
const hasDropdown = !!dropdown
const isActive = hasDropdown && activeDropdown === dropdown
const isThisHovered = hoveredLink === label
const isHighlighted = isActive || isThisHovered
const isDimmed = anyHighlighted && !isHighlighted
const linkClass = cn(
icon ? `${LINK_CELL} gap-[8px]` : LINK_CELL,
'transition-colors duration-200',
isDimmed && 'text-[#F6F6F6]/60'
)
const chevron = icon === 'chevron' && <NavChevron open={isActive} />
if (hasDropdown) {
return (
<li
key={label}
className='relative flex'
onMouseEnter={() => openDropdown(dropdown)}
onMouseLeave={scheduleClose}
>
{label}
{icon === 'chevron' && (
<ChevronDown className='mt-[1.75px] h-[10px] w-[10px] flex-shrink-0 text-[#ECECEC]' />
)}
</Link>
)}
</li>
))}
<li className='flex'>
<button
type='button'
className={cn(linkClass, 'h-full cursor-pointer')}
aria-expanded={isActive}
aria-haspopup='true'
>
{label}
{chevron}
</button>
<div
className={cn(
'-mt-[2px] absolute top-full left-0 z-50',
isActive
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0'
)}
style={{
transform: isActive ? 'translateY(0)' : 'translateY(-6px)',
transition: 'opacity 200ms ease, transform 200ms ease',
}}
>
{dropdown === 'docs' && <DocsDropdown />}
{dropdown === 'blog' && <BlogDropdown />}
</div>
</li>
)
}
return (
<li
key={label}
className='flex'
onMouseEnter={() => setHoveredLink(label)}
onMouseLeave={() => setHoveredLink(null)}
>
{external ? (
<a href={href} target='_blank' rel='noopener noreferrer' className={linkClass}>
{label}
{chevron}
</a>
) : (
<Link href={href} className={linkClass} aria-label={label}>
{label}
{chevron}
</Link>
)}
</li>
)
})}
<li
className={cn(
'flex transition-opacity duration-200',
anyHighlighted && hoveredLink !== 'github' && 'opacity-60'
)}
onMouseEnter={() => setHoveredLink('github')}
onMouseLeave={() => setHoveredLink(null)}
>
<GitHubStars />
</li>
</ul>
<div className='flex-1' />
<div className='hidden flex-1 lg:block' />
{/* CTAs */}
<div className='flex items-center gap-[8px] px-[20px]'>
<div className='hidden items-center gap-[8px] pr-[80px] pl-[20px] lg:flex'>
<Link
href='/login'
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#3d3d3d] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
@@ -113,8 +218,168 @@ export default function Navbar({ logoOnly = false }: NavbarProps) {
Get started
</Link>
</div>
<div className='flex flex-1 items-center justify-end pr-[20px] lg:hidden'>
<button
type='button'
className='flex h-[32px] w-[32px] items-center justify-center rounded-[5px] transition-colors hover:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen((prev) => !prev)}
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={mobileMenuOpen}
>
<MobileMenuIcon open={mobileMenuOpen} />
</button>
</div>
<div
className={cn(
'fixed inset-x-0 top-[52px] bottom-0 z-50 flex flex-col overflow-y-auto bg-[#1C1C1C] font-[430] font-season text-[14px] transition-all duration-200 lg:hidden',
mobileMenuOpen ? 'visible opacity-100' : 'invisible opacity-0'
)}
>
<ul className='flex flex-col'>
{NAV_LINKS.map(({ label, href, external }) => (
<li key={label} className='border-[#2A2A2A] border-b'>
{external ? (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='flex items-center justify-between px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
>
{label}
<ExternalArrowIcon />
</a>
) : (
<Link
href={href}
className='flex items-center px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
>
{label}
</Link>
)}
</li>
))}
<li className='border-[#2A2A2A] border-b'>
<a
href='https://github.com/simstudioai/sim'
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-[8px] px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
>
<GithubOutlineIcon className='h-[14px] w-[14px]' />
GitHub
</a>
</li>
</ul>
<div className='mt-auto flex flex-col gap-[10px] p-[20px]'>
<Link
href='/login'
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#3d3d3d] text-[#ECECEC] text-[14px] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
aria-label='Log in'
>
Log in
</Link>
<Link
href='/signup'
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
onClick={() => setMobileMenuOpen(false)}
aria-label='Get started with Sim'
>
Get started
</Link>
</div>
</div>
</>
)}
</nav>
)
}
interface NavChevronProps {
open: boolean
}
/**
* Animated chevron matching the exact geometry of the emcn ChevronDown SVG.
* Each arm rotates around its midpoint so the center vertex travels up/down
* while the outer endpoints adjust — producing a Stripe-style morph.
*/
function NavChevron({ open }: NavChevronProps) {
return (
<svg width='9' height='6' viewBox='0 0 10 6' fill='none' className='mt-[1.5px] flex-shrink-0'>
<line
x1='1'
y1='1'
x2='5'
y2='5'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
style={{
transformOrigin: '3px 3px',
transform: open ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 250ms cubic-bezier(0.4, 0, 0.2, 1)',
}}
/>
<line
x1='5'
y1='5'
x2='9'
y2='1'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
style={{
transformOrigin: '7px 3px',
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
transition: 'transform 250ms cubic-bezier(0.4, 0, 0.2, 1)',
}}
/>
</svg>
)
}
function MobileMenuIcon({ open }: { open: boolean }) {
if (open) {
return (
<svg width='14' height='14' viewBox='0 0 14 14' fill='none'>
<path
d='M1 1L13 13M13 1L1 13'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
/>
</svg>
)
}
return (
<svg width='16' height='12' viewBox='0 0 16 12' fill='none'>
<path
d='M0 1H16M0 6H16M0 11H16'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
/>
</svg>
)
}
function ExternalArrowIcon() {
return (
<svg width='12' height='12' viewBox='0 0 12 12' fill='none' className='text-[#666]'>
<path
d='M3.5 2.5H9.5V8.5M9 3L3 9'
stroke='currentColor'
strokeWidth='1.2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}

View File

@@ -174,7 +174,7 @@ function PricingCard({ tier }: PricingCardProps) {
export default function Pricing() {
return (
<section id='pricing' aria-labelledby='pricing-heading' className='bg-[#F6F6F6]'>
<div className='px-4 pt-[100px] pb-[80px] sm:px-8 md:px-[80px]'>
<div className='px-4 pt-[60px] pb-[40px] sm:px-8 sm:pt-[80px] sm:pb-0 md:px-[80px] md:pt-[100px]'>
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
<Badge
variant='blue'
@@ -193,7 +193,7 @@ export default function Pricing() {
</h2>
</div>
<div className='mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4'>
<div className='mt-8 grid grid-cols-1 gap-4 sm:mt-10 sm:grid-cols-2 md:mt-12 lg:grid-cols-4'>
{PRICING_TIERS.map((tier) => (
<PricingCard key={tier.id} tier={tier} />
))}

View File

@@ -1,8 +1,8 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
import { AnimatePresence, type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { Badge, ChevronDown } from '@/components/emcn'
@@ -349,8 +349,17 @@ export default function Templates() {
const sectionRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(0)
const [isPreparingTemplate, setIsPreparingTemplate] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const router = useRouter()
useEffect(() => {
const mq = window.matchMedia('(max-width: 1023px)')
setIsMobile(mq.matches)
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ['start 0.9', 'start 0.2'],
@@ -415,8 +424,8 @@ export default function Templates() {
<div className='bg-[#1C1C1C]'>
<DotGrid
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
cols={120}
className='overflow-hidden border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
cols={160}
rows={1}
gap={6}
/>
@@ -440,7 +449,7 @@ export default function Templates() {
</svg>
</div>
<div className='px-[80px] pt-[100px]'>
<div className='px-[20px] pt-[60px] lg:px-[80px] lg:pt-[100px]'>
<div className='flex flex-col items-start gap-[20px]'>
<Badge
variant='blue'
@@ -457,84 +466,132 @@ export default function Templates() {
<h2
id='templates-heading'
className='font-[430] font-season text-[40px] text-white leading-[100%] tracking-[-0.02em]'
className='font-[430] font-season text-[28px] text-white leading-[100%] tracking-[-0.02em] lg:text-[40px]'
>
Ship your agent in minutes
</h2>
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
Pre-built templates for every use casepick one, swap <br />
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[15px] leading-[150%] tracking-[0.02em] lg:text-[18px]'>
Pre-built templates for every use casepick one, swap{' '}
<br className='hidden lg:inline' />
models and tools to fit your stack, and deploy.
</p>
</div>
</div>
<div className='mt-[73px] flex border-[#2A2A2A] border-y'>
<DotGrid
className='w-[80px] shrink-0 overflow-hidden border-[#2A2A2A] border-r p-[6px]'
cols={6}
rows={55}
gap={6}
/>
<div className='mt-[40px] flex border-[#2A2A2A] border-y lg:mt-[73px]'>
<div className='shrink-0'>
<div className='h-full lg:hidden'>
<DotGrid
className='h-full w-[24px] overflow-hidden border-[#2A2A2A] border-r p-[4px]'
cols={2}
rows={55}
gap={4}
/>
</div>
<div className='hidden h-full lg:block'>
<DotGrid
className='h-full w-[80px] overflow-hidden border-[#2A2A2A] border-r p-[6px]'
cols={8}
rows={55}
gap={6}
/>
</div>
</div>
<div className='flex min-w-0 flex-1'>
<div className='flex min-w-0 flex-1 flex-col lg:flex-row'>
<div
role='tablist'
aria-label='Workflow templates'
className='flex w-[300px] shrink-0 flex-col border-[#2A2A2A] border-r'
className='flex w-full shrink-0 flex-col border-[#2A2A2A] lg:w-[300px] lg:border-r'
>
{TEMPLATE_WORKFLOWS.map((workflow, index) => {
const isActive = index === activeIndex
return (
<button
key={workflow.id}
id={`template-tab-${index}`}
type='button'
role='tab'
aria-selected={isActive}
aria-controls={TEMPLATES_PANEL_ID}
onClick={() => setActiveIndex(index)}
className={cn(
'relative text-left',
isActive
? 'z-10'
: 'flex items-center px-[12px] py-[10px] shadow-[inset_0_-1px_0_0_#2A2A2A] last:shadow-none hover:bg-[#232323]/50'
)}
>
{isActive ? (
(() => {
const depth = DEPTH_CONFIGS[workflow.id]
return (
<>
<div
className='absolute top-[-8px] bottom-0 left-0 w-2'
style={{
clipPath: LEFT_WALL_CLIP,
backgroundColor: hexToRgba(depth.color, 0.63),
}}
/>
<div
className='absolute right-[-8px] bottom-0 left-2 h-2'
style={buildBottomWallStyle(depth)}
/>
<div className='-translate-y-2 relative flex translate-x-2 items-center bg-[#242424] px-[12px] py-[10px] shadow-[inset_0_0_0_1.5px_#3E3E3E]'>
<span className='flex-1 font-[430] font-season text-[16px] text-white'>
{workflow.name}
</span>
<ChevronDown
className='-rotate-90 h-[11px] w-[11px] shrink-0'
style={{ color: depth.color }}
<div key={workflow.id}>
<button
id={`template-tab-${index}`}
type='button'
role='tab'
aria-selected={isActive}
aria-controls={TEMPLATES_PANEL_ID}
onClick={() => setActiveIndex(index)}
className={cn(
'relative w-full text-left',
isActive
? 'z-10'
: cn(
'flex items-center px-[12px] py-[10px] hover:bg-[#232323]/50',
index < TEMPLATE_WORKFLOWS.length - 1 &&
'shadow-[inset_0_-1px_0_0_#2A2A2A]'
)
)}
>
{isActive ? (
(() => {
const depth = DEPTH_CONFIGS[workflow.id]
return (
<>
<div
className='absolute top-[-8px] bottom-0 left-0 w-2'
style={{
clipPath: LEFT_WALL_CLIP,
backgroundColor: hexToRgba(depth.color, 0.63),
}}
/>
</div>
</>
)
})()
) : (
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[16px]'>
{workflow.name}
</span>
)}
</button>
<div
className='absolute right-[-8px] bottom-0 left-2 h-2'
style={buildBottomWallStyle(depth)}
/>
<div className='-translate-y-2 relative flex translate-x-2 items-center bg-[#242424] px-[12px] py-[10px] shadow-[inset_0_0_0_1.5px_#3E3E3E]'>
<span className='flex-1 font-[430] font-season text-[16px] text-white'>
{workflow.name}
</span>
<ChevronDown
className='-rotate-90 h-[11px] w-[11px] shrink-0'
style={{ color: depth.color }}
/>
</div>
</>
)
})()
) : (
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[16px]'>
{workflow.name}
</span>
)}
</button>
<AnimatePresence>
{isActive && isMobile && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
className='overflow-hidden'
>
<div className='aspect-[16/10] w-full border-[#2A2A2A] border-y bg-[#1b1b1b]'>
<LandingPreviewWorkflow
workflow={workflow}
animate
fitViewOptions={{ padding: 0.15, maxZoom: 1.3 }}
/>
</div>
<div className='p-[12px]'>
<button
type='button'
onClick={handleUseTemplate}
disabled={isPreparingTemplate}
className='inline-flex h-[32px] w-full cursor-pointer items-center justify-center gap-[6px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] font-[430] font-season text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
>
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
@@ -582,12 +639,24 @@ export default function Templates() {
</div>
</div>
<DotGrid
className='w-[80px] shrink-0 overflow-hidden border-[#2A2A2A] border-l p-[6px]'
cols={6}
rows={55}
gap={6}
/>
<div className='shrink-0'>
<div className='h-full lg:hidden'>
<DotGrid
className='h-full w-[24px] overflow-hidden border-[#2A2A2A] border-l p-[4px]'
cols={2}
rows={55}
gap={4}
/>
</div>
<div className='hidden h-full lg:block'>
<DotGrid
className='h-full w-[80px] overflow-hidden border-[#2A2A2A] border-l p-[6px]'
cols={8}
rows={55}
gap={6}
/>
</div>
</div>
</div>
</div>
</div>

View File

@@ -9,7 +9,7 @@ export function BackLink() {
return (
<Link
href='/studio'
href='/blog'
className='group flex items-center gap-1 text-[#999] text-sm hover:text-[#ECECEC]'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
@@ -21,7 +21,7 @@ export function BackLink() {
<ChevronLeft className='h-4 w-4' aria-hidden='true' />
)}
</span>
Back to Sim Studio
Back to Blog
</Link>
)
}

View File

@@ -6,8 +6,8 @@ import { FAQ } from '@/lib/blog/faq'
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { BackLink } from '@/app/(landing)/studio/[slug]/back-link'
import { ShareButton } from '@/app/(landing)/studio/[slug]/share-button'
import { BackLink } from '@/app/(landing)/blog/[slug]/back-link'
import { ShareButton } from '@/app/(landing)/blog/[slug]/share-button'
export async function generateStaticParams() {
const posts = await getAllPostMeta()
@@ -95,7 +95,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
</div>
))}
</div>
<ShareButton url={`${getBaseUrl()}/studio/${slug}`} title={post.title} />
<ShareButton url={`${getBaseUrl()}/blog/${slug}`} title={post.title} />
</div>
</div>
</div>
@@ -134,7 +134,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
<h2 className='mb-4 font-[500] text-[#ECECEC] text-[24px]'>Related posts</h2>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3'>
{related.map((p) => (
<Link key={p.slug} href={`/studio/${p.slug}`} className='group'>
<Link key={p.slug} href={`/blog/${p.slug}`} className='group'>
<div className='overflow-hidden rounded-lg border border-[#2A2A2A]'>
<Image
src={p.ogImage}

View File

@@ -31,7 +31,7 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
'@context': 'https://schema.org',
'@type': 'Person',
name: author.name,
url: `https://sim.ai/studio/authors/${author.id}`,
url: `https://sim.ai/blog/authors/${author.id}`,
sameAs: author.url ? [author.url] : [],
image: author.avatarUrl,
}
@@ -56,7 +56,7 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
</div>
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
{posts.map((p) => (
<Link key={p.slug} href={`/studio/${p.slug}`} className='group'>
<Link key={p.slug} href={`/blog/${p.slug}`} className='group'>
<div className='overflow-hidden rounded-lg border border-[#2A2A2A]'>
<Image
src={p.ogImage}

View File

@@ -1,16 +1,16 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getAllPostMeta } from '@/lib/blog/registry'
import { PostGrid } from '@/app/(landing)/studio/post-grid'
import { PostGrid } from '@/app/(landing)/blog/post-grid'
export const metadata: Metadata = {
title: 'Studio',
title: 'Blog',
description: 'Announcements, insights, and guides from the Sim team.',
}
export const revalidate = 3600
export default async function StudioIndex({
export default async function BlogIndex({
searchParams,
}: {
searchParams: Promise<{ page?: string; tag?: string }>
@@ -36,11 +36,11 @@ export default async function StudioIndex({
const posts = sorted.slice(start, start + perPage)
// Tag filter chips are intentionally disabled for now.
// const tags = await getAllTags()
const studioJsonLd = {
const blogJsonLd = {
'@context': 'https://schema.org',
'@type': 'Blog',
name: 'Sim Studio',
url: 'https://sim.ai/studio',
name: 'Sim Blog',
url: 'https://sim.ai/blog',
description: 'Announcements, insights, and guides for building AI agent workflows.',
}
@@ -48,10 +48,10 @@ export default async function StudioIndex({
<main className='mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(studioJsonLd) }}
dangerouslySetInnerHTML={{ __html: JSON.stringify(blogJsonLd) }}
/>
<h1 className='mb-3 font-[500] text-[#ECECEC] text-[40px] leading-tight sm:text-[56px]'>
Sim Studio
Blog
</h1>
<p className='mb-10 text-[#999] text-[18px]'>
Announcements, insights, and guides for building AI agent workflows.
@@ -59,9 +59,9 @@ export default async function StudioIndex({
{/* Tag filter chips hidden until we have more posts */}
{/* <div className='mb-10 flex flex-wrap gap-3'>
<Link href='/studio' className={`rounded-full border px-3 py-1 text-sm ${!tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>All</Link>
<Link href='/blog' className={`rounded-full border px-3 py-1 text-sm ${!tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>All</Link>
{tags.map((t) => (
<Link key={t.tag} href={`/studio?tag=${encodeURIComponent(t.tag)}`} className={`rounded-full border px-3 py-1 text-sm ${tag === t.tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>
<Link key={t.tag} href={`/blog?tag=${encodeURIComponent(t.tag)}`} className={`rounded-full border px-3 py-1 text-sm ${tag === t.tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>
{t.tag} ({t.count})
</Link>
))}
@@ -74,7 +74,7 @@ export default async function StudioIndex({
<div className='mt-10 flex items-center justify-center gap-3'>
{pageNum > 1 && (
<Link
href={`/studio?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
href={`/blog?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded-[5px] border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
>
Previous
@@ -85,7 +85,7 @@ export default async function StudioIndex({
</span>
{pageNum < totalPages && (
<Link
href={`/studio?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
href={`/blog?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded-[5px] border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
>
Next

View File

@@ -26,7 +26,7 @@ export function PostGrid({ posts }: { posts: Post[] }) {
return (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
{posts.map((p, index) => (
<Link key={p.slug} href={`/studio/${p.slug}`} className='group flex flex-col'>
<Link key={p.slug} href={`/blog/${p.slug}`} className='group flex flex-col'>
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-[#2A2A2A] transition-colors duration-300 hover:border-[#3d3d3d]'>
{/* Image container with fixed aspect ratio to prevent layout shift */}
<div className='relative aspect-video w-full overflow-hidden'>

View File

@@ -11,7 +11,7 @@ export async function GET() {
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Sim Studio</title>
<title>Sim Blog</title>
<link>${site}</link>
<description>Announcements, insights, and guides for AI agent workflows.</description>
${items

View File

@@ -13,7 +13,7 @@ export default async function TagsIndex() {
<h1 className='mb-6 font-[500] text-[#ECECEC] text-[32px] leading-tight'>Browse by tag</h1>
<div className='flex flex-wrap gap-3'>
<Link
href='/studio'
href='/blog'
className='rounded-full border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
>
All
@@ -21,7 +21,7 @@ export default async function TagsIndex() {
{tags.map((t) => (
<Link
key={t.tag}
href={`/studio?tag=${encodeURIComponent(t.tag)}`}
href={`/blog?tag=${encodeURIComponent(t.tag)}`}
className='rounded-full border border-[#3d3d3d] px-3 py-1 text-[#ECECEC] text-sm transition-colors hover:bg-[#2A2A2A]'
>
{t.tag} ({t.count})

View File

@@ -57,10 +57,10 @@ export default function Footer({ fullWidth = false }: FooterProps) {
Enterprise
</Link>
<Link
href='/studio'
href='/blog'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Sim Studio
Blog
</Link>
<Link
href='/changelog'

View File

@@ -23,7 +23,7 @@ export default function LegalLayout({ title, children }: LegalLayoutProps) {
</div>
</div>
{isHosted && <Footer />}
{isHosted && <Footer hideCTA />}
</main>
)
}

View File

@@ -13,6 +13,7 @@ export type AppSession = {
emailVerified?: boolean
name?: string | null
image?: string | null
role?: string
createdAt?: Date
updatedAt?: Date
} | null

View File

@@ -20,7 +20,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
pathname.startsWith('/verify') ||
pathname.startsWith('/changelog') ||
pathname.startsWith('/chat') ||
pathname.startsWith('/studio') ||
pathname.startsWith('/blog') ||
pathname.startsWith('/resume') ||
pathname.startsWith('/form') ||
pathname.startsWith('/oauth')

View File

@@ -302,6 +302,7 @@ export async function POST(req: NextRequest) {
goRoute: '/api/copilot',
autoExecuteTools: true,
interactive: true,
promptForToolApproval: true,
},
})
@@ -315,6 +316,7 @@ export async function POST(req: NextRequest) {
goRoute: '/api/copilot',
autoExecuteTools: true,
interactive: true,
promptForToolApproval: true,
})
const responseData = {

View File

@@ -7,7 +7,11 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/copilot/chat-streaming'
import {
createSSEStream,
SSE_RESPONSE_HEADERS,
waitForPendingChatStream,
} from '@/lib/copilot/chat-streaming'
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
import { processContextsServer, resolveActiveResourceContext } from '@/lib/copilot/process-contents'
import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/request-helpers'
@@ -244,6 +248,10 @@ export async function POST(req: NextRequest) {
{ selectedModel: '' }
)
if (actualChatId) {
await waitForPendingChatStream(actualChatId)
}
const stream = createSSEStream({
requestPayload,
userId: authenticatedUserId,
@@ -261,7 +269,8 @@ export async function POST(req: NextRequest) {
chatId: actualChatId,
goRoute: '/api/mothership',
autoExecuteTools: true,
interactive: false,
interactive: true,
promptForToolApproval: false,
onComplete: async (result: OrchestratorResult) => {
if (!actualChatId) return

View File

@@ -1,42 +0,0 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
const logger = createLogger('SuperUserAPI')
export const revalidate = 0
// GET /api/user/super-user - Check if current user is a super user (database status)
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized super user status check attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const currentUser = await db
.select({ isSuperUser: user.isSuperUser })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
if (currentUser.length === 0) {
logger.warn(`[${requestId}] User not found: ${session.user.id}`)
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json({
isSuperUser: currentUser[0].isSuperUser,
})
} catch (error) {
logger.error(`[${requestId}] Error checking super user status`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -72,7 +72,7 @@ export async function GET() {
emailPreferences: userSettings.emailPreferences ?? {},
billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true,
showTrainingControls: userSettings.showTrainingControls ?? false,
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
superUserModeEnabled: userSettings.superUserModeEnabled ?? false,
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
snapToGridSize: userSettings.snapToGridSize ?? 0,
showActionBar: userSettings.showActionBar ?? true,

View File

@@ -267,11 +267,24 @@ async function createRejectedTask(
* Format: "username@domain.com" or "Display Name <username@domain.com>"
*/
function extractSenderEmail(from: string): string {
const match = from.match(/<([^>]+)>/)
return (match?.[1] || from).toLowerCase().trim()
const openBracket = from.indexOf('<')
const closeBracket = from.indexOf('>', openBracket + 1)
if (openBracket !== -1 && closeBracket !== -1) {
return from
.substring(openBracket + 1, closeBracket)
.toLowerCase()
.trim()
}
return from.toLowerCase().trim()
}
function extractDisplayName(from: string): string | null {
const match = from.match(/^(.+?)\s*</)
return match?.[1]?.trim().replace(/^"|"$/g, '') || null
const openBracket = from.indexOf('<')
if (openBracket <= 0) return null
const name = from.substring(0, openBracket).trim()
if (!name) return null
if (name.startsWith('"') && name.endsWith('"')) {
return name.slice(1, -1) || null
}
return name
}

View File

@@ -11,7 +11,7 @@ export default function ChangelogLayout({ children }: { children: React.ReactNod
<Navbar />
</header>
{children}
<Footer />
<Footer hideCTA />
</div>
)
}

View File

@@ -14,7 +14,7 @@ Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over
- [Homepage](${baseUrl}): Product overview, features, and pricing
- [Templates](${baseUrl}/templates): Pre-built workflow templates to get started quickly
- [Changelog](${baseUrl}/changelog): Product updates and release notes
- [Sim Studio Blog](${baseUrl}/studio): Announcements, insights, and guides
- [Sim Blog](${baseUrl}/blog): Announcements, insights, and guides
## Documentation

View File

@@ -1,28 +1,33 @@
'use client'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import AuthBackground from '@/app/(auth)/components/auth-background'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import Navbar from '@/app/(home)/components/navbar/navbar'
export default function NotFound() {
const router = useRouter()
const CTA_BASE =
'inline-flex items-center h-[32px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px]'
export default function NotFound() {
return (
<AuthBackground className='dark font-[430] font-season'>
<main className='relative flex min-h-full flex-col text-[#ECECEC]'>
<header className='shrink-0 bg-[#1C1C1C]'>
<Navbar />
</header>
<div className='relative z-30 flex flex-1 flex-col items-center justify-center px-4 pb-24'>
<h1 className='font-[500] text-[48px] tracking-tight'>Page Not Found</h1>
<p className='mt-2 text-[#999] text-[16px]'>
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<div className='mt-8 w-full max-w-[200px]'>
<BrandedButton onClick={() => router.push('/')} showArrow={false}>
Return to Home
</BrandedButton>
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
<div className='flex flex-col items-center gap-[12px]'>
<h1 className='font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
Page Not Found
</h1>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<div className='mt-[12px] flex items-center gap-[8px]'>
<Link
href='/'
className={`${CTA_BASE} gap-[8px] border-[#FFFFFF] bg-[#FFFFFF] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
>
Return to Home
</Link>
</div>
</div>
</div>
</main>

View File

@@ -13,11 +13,11 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
lastModified: now,
},
{
url: `${baseUrl}/studio`,
url: `${baseUrl}/blog`,
lastModified: now,
},
{
url: `${baseUrl}/studio/tags`,
url: `${baseUrl}/blog/tags`,
lastModified: now,
},
{

View File

@@ -148,7 +148,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
const [currentUserOrgRoles, setCurrentUserOrgRoles] = useState<
Array<{ organizationId: string; role: string }>
>([])
const [isSuperUser, setIsSuperUser] = useState(false)
const isSuperUser = session?.user?.role === 'admin'
const [isUsing, setIsUsing] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [isApproving, setIsApproving] = useState(false)
@@ -186,21 +186,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
}
}
const fetchSuperUserStatus = async () => {
if (!currentUserId) return
try {
const response = await fetch('/api/user/super-user')
if (response.ok) {
const data = await response.json()
setIsSuperUser(data.isSuperUser || false)
}
} catch (error) {
logger.error('Error fetching super user status:', error)
}
}
fetchSuperUserStatus()
fetchUserOrganizations()
}, [currentUserId])

View File

@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import {
Button,
Columns2,
Download,
DropdownMenu,
DropdownMenuContent,
@@ -48,6 +49,7 @@ import {
ResourceHeader,
timeCell,
} from '@/app/workspace/[workspaceId]/components'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import {
FileViewer,
isPreviewable,
@@ -157,7 +159,7 @@ export function Files() {
const [creatingFile, setCreatingFile] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [showPreview, setShowPreview] = useState(true)
const [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [contextMenuFile, setContextMenuFile] = useState<WorkspaceFileRecord | null>(null)
@@ -312,7 +314,7 @@ export function Files() {
if (isDirty) {
setShowUnsavedChangesAlert(true)
} else {
setShowPreview(false)
setPreviewMode('editor')
setSelectedFileId(null)
}
}, [isDirty])
@@ -382,13 +384,11 @@ export function Files() {
]
)
const handleTogglePreview = useCallback(() => setShowPreview((prev) => !prev), [])
const handleDiscardChanges = useCallback(() => {
setShowUnsavedChangesAlert(false)
setIsDirty(false)
setSaveStatus('idle')
setShowPreview(false)
setPreviewMode('editor')
setSelectedFileId(null)
}, [])
@@ -480,8 +480,14 @@ export function Files() {
if (justCreatedFileIdRef.current && !isJustCreated) {
justCreatedFileIdRef.current = null
}
setShowPreview(!isJustCreated)
}, [selectedFileId])
if (isJustCreated) {
setPreviewMode('editor')
} else {
const file = selectedFileId ? files.find((f) => f.id === selectedFileId) : null
const canPreview = file ? isPreviewable(file) : false
setPreviewMode(canPreview ? 'preview' : 'editor')
}
}, [selectedFileId, files])
useEffect(() => {
if (!selectedFile) return
@@ -504,10 +510,23 @@ export function Files() {
return () => window.removeEventListener('beforeunload', handler)
}, [isDirty])
const handleCyclePreviewMode = useCallback(() => {
setPreviewMode((prev) => {
if (prev === 'editor') return 'split'
if (prev === 'split') return 'preview'
return 'editor'
})
}, [])
const handleTogglePreview = useCallback(() => {
setPreviewMode((prev) => (prev === 'preview' ? 'editor' : 'preview'))
}, [])
const fileActions = useMemo<HeaderAction[]>(() => {
if (!selectedFile) return []
const canEditText = isTextEditable(selectedFile)
const canPreview = isPreviewable(selectedFile)
const hasSplitView = canEditText && canPreview
const saveLabel =
saveStatus === 'saving'
@@ -518,16 +537,12 @@ export function Files() {
? 'Save failed'
: 'Save'
const nextModeLabel =
previewMode === 'editor' ? 'Split' : previewMode === 'split' ? 'Preview' : 'Edit'
const nextModeIcon =
previewMode === 'editor' ? Columns2 : previewMode === 'split' ? Eye : Pencil
return [
...(canPreview
? [
{
label: showPreview ? 'Edit' : 'Preview',
icon: showPreview ? Pencil : Eye,
onClick: handleTogglePreview,
},
]
: []),
...(canEditText
? [
{
@@ -540,6 +555,23 @@ export function Files() {
},
]
: []),
...(hasSplitView
? [
{
label: nextModeLabel,
icon: nextModeIcon,
onClick: handleCyclePreviewMode,
},
]
: canPreview
? [
{
label: previewMode === 'preview' ? 'Edit' : 'Preview',
icon: previewMode === 'preview' ? Pencil : Eye,
onClick: handleTogglePreview,
},
]
: []),
{
label: 'Download',
icon: Download,
@@ -554,7 +586,8 @@ export function Files() {
}, [
selectedFile,
saveStatus,
showPreview,
previewMode,
handleCyclePreviewMode,
handleTogglePreview,
handleSave,
isDirty,
@@ -580,8 +613,6 @@ export function Files() {
}
if (selectedFile) {
const canPreview = isPreviewable(selectedFile)
return (
<>
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
@@ -595,7 +626,7 @@ export function Files() {
file={selectedFile}
workspaceId={workspaceId}
canEdit={userPermissions.canEdit === true}
showPreview={showPreview && canPreview}
previewMode={previewMode}
autoFocus={justCreatedFileIdRef.current === selectedFile.id}
onDirtyChange={setIsDirty}
onSaveStatusChange={setSaveStatus}

View File

@@ -58,7 +58,7 @@ export function AgentGroup({
}, [expanded])
return (
<div className='flex flex-col gap-1.5'>
<div className='flex flex-col gap-[6px]'>
{hasItems ? (
<button
type='button'
@@ -87,7 +87,7 @@ export function AgentGroup({
{hasItems && mounted && (
<div
className={cn(
'flex flex-col gap-3 transition-opacity duration-300 ease-out',
'flex flex-col gap-[6px] transition-opacity duration-300 ease-out',
expanded ? 'opacity-100' : 'opacity-0'
)}
>
@@ -98,14 +98,15 @@ export function AgentGroup({
toolName={item.data.toolName}
displayTitle={item.data.displayTitle}
status={item.data.status}
result={item.data.result}
/>
) : (
<p
<span
key={`text-${idx}`}
className='whitespace-pre-wrap pl-[24px] font-base text-[13px] text-[var(--text-secondary)]'
className='pl-[24px] font-base text-[13px] text-[var(--text-secondary)]'
>
{item.content.trim()}
</p>
</span>
)
)}
</div>

View File

@@ -1,7 +1,29 @@
import { Loader } from '@/components/emcn'
import type { ToolCallStatus } from '../../../../types'
'use client'
import { useMemo } from 'react'
import { PillsRing } from '@/components/emcn'
import type { ToolCallResult, ToolCallStatus } from '../../../../types'
import { getToolIcon } from '../../utils'
/** Tools that render as cards with result data on success. */
const CARD_TOOLS = new Set<string>([
'function_execute',
'search_online',
'scrape_page',
'get_page_contents',
'search_library_docs',
'superagent',
'run',
'plan',
'debug',
'edit',
'fast_edit',
'custom_tool',
'research',
'agent',
'job',
])
function CircleCheck({ className }: { className?: string }) {
return (
<svg
@@ -40,29 +62,100 @@ export function CircleStop({ className }: { className?: string }) {
)
}
interface ToolCallItemProps {
function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: string }) {
if (status === 'executing') {
return <PillsRing className='h-[15px] w-[15px] text-[var(--text-tertiary)]' animate />
}
if (status === 'cancelled') {
return <CircleStop className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
}
const Icon = getToolIcon(toolName)
if (Icon) {
return <Icon className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
}
return <CircleCheck className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
}
function FlatToolLine({
toolName,
displayTitle,
status,
}: {
toolName: string
displayTitle: string
status: ToolCallStatus
}
export function ToolCallItem({ toolName, displayTitle, status }: ToolCallItemProps) {
const Icon = getToolIcon(toolName)
}) {
return (
<div className='flex items-center gap-[8px] pl-[24px]'>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
{status === 'executing' ? (
<Loader className='h-[15px] w-[15px] text-[var(--text-tertiary)]' animate />
) : status === 'cancelled' ? (
<CircleStop className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
) : Icon ? (
<Icon className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
) : (
<CircleCheck className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
)}
<StatusIcon status={status} toolName={toolName} />
</div>
<span className='font-base text-[13px] text-[var(--text-secondary)]'>{displayTitle}</span>
</div>
)
}
function formatToolOutput(output: unknown): string {
if (output === null || output === undefined) return ''
if (typeof output === 'string') return output
try {
return JSON.stringify(output, null, 2)
} catch {
return String(output)
}
}
interface ToolCallItemProps {
toolName: string
displayTitle: string
status: ToolCallStatus
result?: ToolCallResult
}
export function ToolCallItem({ toolName, displayTitle, status, result }: ToolCallItemProps) {
const showCard =
CARD_TOOLS.has(toolName) &&
status === 'success' &&
result?.output !== undefined &&
result?.output !== null
if (showCard) {
return <ToolCallCard toolName={toolName} displayTitle={displayTitle} result={result!} />
}
return <FlatToolLine toolName={toolName} displayTitle={displayTitle} status={status} />
}
function ToolCallCard({
toolName,
displayTitle,
result,
}: {
toolName: string
displayTitle: string
result: ToolCallResult
}) {
const body = useMemo(() => formatToolOutput(result.output), [result.output])
const Icon = getToolIcon(toolName)
const ResolvedIcon = Icon ?? CircleCheck
return (
<div className='animate-stream-fade-in pl-[24px]'>
<div className='overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-3)]'>
<div className='flex items-center gap-[8px] px-[10px] py-[6px]'>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
<ResolvedIcon className='h-[15px] w-[15px] text-[var(--text-tertiary)]' />
</div>
<span className='font-base text-[13px] text-[var(--text-secondary)]'>{displayTitle}</span>
</div>
{body && (
<div className='border-[var(--border)] border-t px-[10px] py-[6px]'>
<pre className='max-h-[200px] overflow-y-auto whitespace-pre-wrap break-all font-mono text-[12px] text-[var(--text-body)] leading-[1.5]'>
{body}
</pre>
</div>
)}
</div>
</div>
)
}

View File

@@ -216,9 +216,9 @@ export function ChatContent({ content, isStreaming = false, onOptionSelect }: Ch
return (
<div className='space-y-3'>
{parsed.segments.map((segment, i) => {
if (segment.type === 'text') {
if (segment.type === 'text' || segment.type === 'thinking') {
return (
<div key={`text-${i}`} className={PROSE_CLASSES}>
<div key={`${segment.type}-${i}`} className={PROSE_CLASSES}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
{segment.content}
</ReactMarkdown>

View File

@@ -27,6 +27,7 @@ export interface CredentialTagData {
export type ContentSegment =
| { type: 'text'; content: string }
| { type: 'thinking'; content: string }
| { type: 'options'; data: OptionsTagData }
| { type: 'usage_upgrade'; data: UsageUpgradeTagData }
| { type: 'credential'; data: CredentialTagData }
@@ -36,7 +37,7 @@ export interface ParsedSpecialContent {
hasPendingTag: boolean
}
const SPECIAL_TAG_NAMES = ['options', 'usage_upgrade', 'credential'] as const
const SPECIAL_TAG_NAMES = ['thinking', 'options', 'usage_upgrade', 'credential'] as const
/**
* Parses inline special tags (`<options>`, `<usage_upgrade>`) from streamed
@@ -103,11 +104,17 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
}
const body = content.slice(bodyStart, closeIdx)
try {
const data = JSON.parse(body)
segments.push({ type: nearestTagName as 'options' | 'usage_upgrade' | 'credential', data })
} catch {
/* malformed JSON — drop the tag silently */
if (nearestTagName === 'thinking') {
if (body.trim()) {
segments.push({ type: 'thinking', content: body })
}
} else {
try {
const data = JSON.parse(body)
segments.push({ type: nearestTagName as 'options' | 'usage_upgrade' | 'credential', data })
} catch {
/* malformed JSON — drop the tag silently */
}
}
cursor = closeIdx + closeTag.length
@@ -137,6 +144,8 @@ interface SpecialTagsProps {
*/
export function SpecialTags({ segment, onOptionSelect }: SpecialTagsProps) {
switch (segment.type) {
case 'thinking':
return null
case 'options':
return <OptionsDisplay data={segment.data} onSelect={onOptionSelect} />
case 'usage_upgrade':

View File

@@ -1,9 +1,9 @@
'use client'
import type { ContentBlock, OptionItem, SubagentName, ToolCallData } from '../../types'
import { SUBAGENT_LABELS } from '../../types'
import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types'
import type { AgentGroupItem } from './components'
import { AgentGroup, ChatContent, CircleStop, Options } from './components'
import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } from './components'
interface TextSegment {
type: 'text'
@@ -47,8 +47,12 @@ function toToolData(tc: NonNullable<ContentBlock['toolCall']>): ToolCallData {
return {
id: tc.id,
toolName: tc.name,
displayTitle: tc.displayTitle || formatToolName(tc.name),
displayTitle:
tc.displayTitle ||
TOOL_UI_METADATA[tc.name as keyof typeof TOOL_UI_METADATA]?.title ||
formatToolName(tc.name),
status: tc.status,
result: tc.result,
}
}
@@ -78,6 +82,15 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
if (block.type === 'text') {
if (!block.content?.trim()) continue
if (block.subagent && group && group.agentName === block.subagent) {
const lastItem = group.items[group.items.length - 1]
if (lastItem?.type === 'text') {
lastItem.content += block.content
} else {
group.items.push({ type: 'text', content: block.content })
}
continue
}
if (group) {
segments.push(group)
group = null
@@ -177,6 +190,14 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
continue
}
if (block.type === 'subagent_end') {
if (group) {
segments.push(group)
group = null
}
continue
}
if (block.type === 'stopped') {
if (group) {
segments.push(group)
@@ -214,6 +235,27 @@ export function MessageContent({
if (segments.length === 0) return null
const lastSegment = segments[segments.length - 1]
const hasTrailingContent = lastSegment.type === 'text' || lastSegment.type === 'stopped'
let allLastGroupToolsDone = false
if (lastSegment.type === 'agent_group') {
const toolItems = lastSegment.items.filter((item) => item.type === 'tool')
allLastGroupToolsDone =
toolItems.length > 0 &&
toolItems.every(
(t) =>
t.type === 'tool' &&
(t.data.status === 'success' ||
t.data.status === 'error' ||
t.data.status === 'cancelled')
)
}
const hasSubagentEnded = blocks.some((b) => b.type === 'subagent_end')
const showTrailingThinking =
isStreaming && !hasTrailingContent && (hasSubagentEnded || allLastGroupToolsDone)
return (
<div className='space-y-[10px]'>
{segments.map((segment, i) => {
@@ -270,6 +312,11 @@ export function MessageContent({
)
}
})}
{showTrailingThinking && (
<div className='animate-stream-fade-in-delayed opacity-0'>
<PendingTagIndicator />
</div>
)}
</div>
)
}

View File

@@ -2,7 +2,6 @@ import type { ComponentType, SVGProps } from 'react'
import {
Asterisk,
Blimp,
BubbleChatPreview,
Bug,
Calendar,
ClipboardList,
@@ -23,6 +22,7 @@ import {
Wrench,
} from '@/components/emcn'
import { Table as TableIcon } from '@/components/emcn/icons'
import { AgentIcon } from '@/components/icons'
import type { MothershipToolName, SubagentName } from '../../types'
export type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
@@ -53,7 +53,7 @@ const TOOL_ICONS: Record<MothershipToolName | SubagentName | 'mothership', IconC
knowledge_base: Database,
table: TableIcon,
job: Calendar,
agent: BubbleChatPreview,
agent: AgentIcon,
custom_tool: Wrench,
research: Search,
plan: ClipboardList,

View File

@@ -197,7 +197,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>{isExecuting ? 'Stop' : 'Run'}</p>
<p>{isExecuting ? 'Stop' : 'Run workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>
</>

View File

@@ -123,19 +123,20 @@ const RESOURCE_INVALIDATORS: Record<
MothershipResourceType,
(qc: QueryClient, workspaceId: string, resourceId: string) => void
> = {
table: (qc, wId, id) => {
qc.invalidateQueries({ queryKey: tableKeys.list(wId) })
table: (qc, _wId, id) => {
qc.invalidateQueries({ queryKey: tableKeys.lists() })
qc.invalidateQueries({ queryKey: tableKeys.detail(id) })
},
file: (qc, wId, id) => {
qc.invalidateQueries({ queryKey: workspaceFilesKeys.list(wId) })
qc.invalidateQueries({ queryKey: workspaceFilesKeys.lists() })
qc.invalidateQueries({ queryKey: workspaceFilesKeys.content(wId, id) })
qc.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
},
workflow: (qc, wId) => {
qc.invalidateQueries({ queryKey: workflowKeys.list(wId) })
workflow: (qc, _wId) => {
qc.invalidateQueries({ queryKey: workflowKeys.lists() })
},
knowledgebase: (qc, wId, id) => {
qc.invalidateQueries({ queryKey: knowledgeKeys.list(wId) })
knowledgebase: (qc, _wId, id) => {
qc.invalidateQueries({ queryKey: knowledgeKeys.lists() })
qc.invalidateQueries({ queryKey: knowledgeKeys.detail(id) })
},
}

View File

@@ -41,6 +41,12 @@ const PREVIEW_MODE_ICONS = {
preview: Pencil,
} satisfies Record<PreviewMode, (props: ComponentProps<typeof Eye>) => ReactNode>
const PREVIEW_MODE_LABELS: Record<PreviewMode, string> = {
editor: 'Split Mode',
split: 'Preview Mode',
preview: 'Edit Mode',
}
/**
* Builds a `type:id` -> current name lookup from live query data so resource
* tabs always reflect the latest name even after a rename.
@@ -273,103 +279,105 @@ export function ResourceTabs({
<p>Collapse</p>
</Tooltip.Content>
</Tooltip.Root>
<div
ref={scrollNodeRef}
className={cn(
'flex min-w-0 flex-1 items-center overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
RESOURCE_TAB_GAP_CLASS
)}
onDragOver={(e) => {
e.preventDefault()
startEdgeScroll(e.clientX)
}}
onDrop={handleDrop}
>
{resources.map((resource, idx) => {
const config = getResourceConfig(resource.type)
const displayName = nameLookup.get(`${resource.type}:${resource.id}`) ?? resource.title
const isActive = activeId === resource.id
const isHovered = hoveredTabId === resource.id
const isDragging = draggedIdx === idx
const showGapBefore =
dropGapIdx === idx &&
draggedIdx !== null &&
draggedIdx !== idx &&
draggedIdx !== idx - 1
const showGapAfter =
idx === resources.length - 1 &&
dropGapIdx === resources.length &&
draggedIdx !== null &&
draggedIdx !== idx
<div className={cn('flex min-w-0 flex-1 items-center', RESOURCE_TAB_GAP_CLASS)}>
<div
ref={scrollNodeRef}
className={cn(
'flex min-w-0 items-center overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
RESOURCE_TAB_GAP_CLASS
)}
onDragOver={(e) => {
e.preventDefault()
startEdgeScroll(e.clientX)
}}
onDrop={handleDrop}
>
{resources.map((resource, idx) => {
const config = getResourceConfig(resource.type)
const displayName = nameLookup.get(`${resource.type}:${resource.id}`) ?? resource.title
const isActive = activeId === resource.id
const isHovered = hoveredTabId === resource.id
const isDragging = draggedIdx === idx
const showGapBefore =
dropGapIdx === idx &&
draggedIdx !== null &&
draggedIdx !== idx &&
draggedIdx !== idx - 1
const showGapAfter =
idx === resources.length - 1 &&
dropGapIdx === resources.length &&
draggedIdx !== null &&
draggedIdx !== idx
return (
<div key={resource.id} className='relative flex shrink-0 items-center'>
{showGapBefore && (
<div className='-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 left-0 z-10 h-[16px] w-[2px] rounded-full bg-[var(--text-subtle)]' />
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragLeave={handleDragLeave}
onDragEnd={handleDragEnd}
onMouseDown={(e) => {
if (e.button === 1 && chatId) {
e.preventDefault()
handleRemove(e, resource)
}
}}
onClick={() => onSelect(resource.id)}
onMouseEnter={() => setHoveredTabId(resource.id)}
onMouseLeave={() => setHoveredTabId(null)}
className={cn(
'group relative shrink-0 bg-transparent px-[8px] py-[4px] pr-[22px] text-[12px] transition-opacity duration-150',
isActive && 'bg-[var(--surface-4)]',
isDragging && 'opacity-30'
)}
>
{config.renderTabIcon(resource, 'mr-[6px] h-[14px] w-[14px]')}
{displayName}
{(isHovered || isActive) && chatId && (
<span
role='button'
tabIndex={-1}
onClick={(e) => handleRemove(e, resource)}
onKeyDown={(e) => {
if (e.key === 'Enter')
handleRemove(e as unknown as React.MouseEvent, resource)
}}
className='-translate-y-1/2 absolute top-1/2 right-[4px] flex items-center justify-center rounded-[4px] p-[1px] hover:bg-[var(--surface-5)]'
aria-label={`Close ${displayName}`}
>
<svg
className='h-[10px] w-[10px] text-[var(--text-icon)]'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2.5'
strokeLinecap='round'
strokeLinejoin='round'
return (
<div key={resource.id} className='relative flex shrink-0 items-center'>
{showGapBefore && (
<div className='-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 left-0 z-10 h-[16px] w-[2px] rounded-full bg-[var(--text-subtle)]' />
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragLeave={handleDragLeave}
onDragEnd={handleDragEnd}
onMouseDown={(e) => {
if (e.button === 1 && chatId) {
e.preventDefault()
handleRemove(e, resource)
}
}}
onClick={() => onSelect(resource.id)}
onMouseEnter={() => setHoveredTabId(resource.id)}
onMouseLeave={() => setHoveredTabId(null)}
className={cn(
'group relative shrink-0 bg-transparent px-[8px] py-[4px] pr-[22px] text-[12px] transition-opacity duration-150',
isActive && 'bg-[var(--surface-4)]',
isDragging && 'opacity-30'
)}
>
{config.renderTabIcon(resource, 'mr-[6px] h-[14px] w-[14px]')}
{displayName}
{(isHovered || isActive) && chatId && (
<span
role='button'
tabIndex={-1}
onClick={(e) => handleRemove(e, resource)}
onKeyDown={(e) => {
if (e.key === 'Enter')
handleRemove(e as unknown as React.MouseEvent, resource)
}}
className='-translate-y-1/2 absolute top-1/2 right-[4px] flex items-center justify-center rounded-[4px] p-[1px] hover:bg-[var(--surface-5)]'
aria-label={`Close ${displayName}`}
>
<path d='M18 6 6 18M6 6l12 12' />
</svg>
</span>
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>{displayName}</p>
</Tooltip.Content>
</Tooltip.Root>
{showGapAfter && (
<div className='-translate-y-1/2 pointer-events-none absolute top-1/2 right-0 z-10 h-[16px] w-[2px] translate-x-1/2 rounded-full bg-[var(--text-subtle)]' />
)}
</div>
)
})}
<svg
className='h-[10px] w-[10px] text-[var(--text-icon)]'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2.5'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M18 6 6 18M6 6l12 12' />
</svg>
</span>
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>{displayName}</p>
</Tooltip.Content>
</Tooltip.Root>
{showGapAfter && (
<div className='-translate-y-1/2 pointer-events-none absolute top-1/2 right-0 z-10 h-[16px] w-[2px] translate-x-1/2 rounded-full bg-[var(--text-subtle)]' />
)}
</div>
)
})}
</div>
{chatId && (
<AddResourceDropdown
workspaceId={workspaceId}
@@ -395,7 +403,7 @@ export function ResourceTabs({
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>Preview mode</p>
<p>{PREVIEW_MODE_LABELS[previewMode]}</p>
</Tooltip.Content>
</Tooltip.Root>
)}

View File

@@ -59,12 +59,12 @@ export const MothershipView = memo(function MothershipView({
return (
<div
className={cn(
'flex h-full flex-col overflow-hidden border-[var(--border)] transition-[width,min-width,border-width] duration-300 ease-out',
isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-[50%] min-w-[400px] border-l',
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-300 ease-out',
isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-[60%] border-l',
className
)}
>
<div className='flex min-h-0 min-w-[400px] flex-1 flex-col'>
<div className='flex min-h-0 flex-1 flex-col'>
<ResourceTabs
workspaceId={workspaceId}
chatId={chatId}

View File

@@ -18,16 +18,16 @@ export function QueuedMessages({ messageQueue, onRemove, onSendNow, onEdit }: Qu
if (messageQueue.length === 0) return null
return (
<div className='-mb-[12px] mx-[14px] overflow-hidden rounded-t-[16px] border border-[var(--border-1)] border-b-0 bg-[var(--surface-2)] pb-[12px] dark:bg-[var(--surface-3)]'>
<div className='-mb-[12px] mx-[14px] overflow-hidden rounded-t-[16px] border border-[var(--border-1)] border-b-0 bg-[var(--surface-3)] pb-[12px]'>
<button
type='button'
onClick={() => setIsExpanded(!isExpanded)}
className='flex w-full items-center gap-[6px] px-[14px] py-[8px] transition-colors hover:bg-black/[0.03] dark:hover:bg-white/[0.03]'
className='flex w-full items-center gap-[6px] px-[14px] py-[8px] transition-colors hover:bg-[var(--surface-active)]'
>
{isExpanded ? (
<ChevronDown className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
<ChevronDown className='h-[14px] w-[14px] text-[var(--text-icon)]' />
) : (
<ChevronRight className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
<ChevronRight className='h-[14px] w-[14px] text-[var(--text-icon)]' />
)}
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
{messageQueue.length} Queued
@@ -39,7 +39,7 @@ export function QueuedMessages({ messageQueue, onRemove, onSendNow, onEdit }: Qu
{messageQueue.map((msg) => (
<div
key={msg.id}
className='flex items-center gap-[8px] px-[14px] py-[6px] transition-colors hover:bg-black/[0.03] dark:hover:bg-white/[0.03]'
className='flex items-center gap-[8px] px-[14px] py-[6px] transition-colors hover:bg-[var(--surface-active)]'
>
<div className='flex h-[16px] w-[16px] shrink-0 items-center justify-center'>
<div className='h-[10px] w-[10px] rounded-full border-[1.5px] border-[var(--text-tertiary)]/40' />
@@ -58,7 +58,7 @@ export function QueuedMessages({ messageQueue, onRemove, onSendNow, onEdit }: Qu
e.stopPropagation()
onEdit(msg.id)
}}
className='rounded-[6px] p-[5px] text-[var(--text-tertiary)] transition-colors hover:bg-black/[0.06] hover:text-[var(--text-primary)] dark:hover:bg-white/[0.06]'
className='rounded-[6px] p-[5px] text-[var(--text-icon)] transition-colors hover:bg-[var(--surface-active)] hover:text-[var(--text-primary)]'
>
<Pencil className='h-[13px] w-[13px]' />
</button>
@@ -76,7 +76,7 @@ export function QueuedMessages({ messageQueue, onRemove, onSendNow, onEdit }: Qu
e.stopPropagation()
void onSendNow(msg.id)
}}
className='rounded-[6px] p-[5px] text-[var(--text-tertiary)] transition-colors hover:bg-black/[0.06] hover:text-[var(--text-primary)] dark:hover:bg-white/[0.06]'
className='rounded-[6px] p-[5px] text-[var(--text-icon)] transition-colors hover:bg-[var(--surface-active)] hover:text-[var(--text-primary)]'
>
<ArrowUp className='h-[13px] w-[13px]' />
</button>
@@ -94,7 +94,7 @@ export function QueuedMessages({ messageQueue, onRemove, onSendNow, onEdit }: Qu
e.stopPropagation()
onRemove(msg.id)
}}
className='rounded-[6px] p-[5px] text-[var(--text-tertiary)] transition-colors hover:bg-black/[0.06] hover:text-[var(--text-primary)] dark:hover:bg-white/[0.06]'
className='rounded-[6px] p-[5px] text-[var(--text-icon)] transition-colors hover:bg-[var(--surface-active)] hover:text-[var(--text-primary)]'
>
<Trash2 className='h-[13px] w-[13px]' />
</button>

View File

@@ -11,7 +11,6 @@ import {
Hammer,
Integration,
Layout,
Library,
Mail,
Pencil,
Rocket,
@@ -199,7 +198,7 @@ export const TEMPLATES: TemplatePrompt[] = [
tags: ['sales', 'content', 'enterprise'],
},
{
icon: Library,
icon: File,
title: 'Competitive battle cards',
prompt:
'Create an agent that deep-researches each of my competitors using web search — their product features, pricing, positioning, strengths, and weaknesses — and generates a structured battle card document for each one that my sales team can reference during calls.',
@@ -830,7 +829,7 @@ export const TEMPLATES: TemplatePrompt[] = [
tags: ['hr', 'automation', 'team'],
},
{
icon: Library,
icon: ClipboardList,
title: 'Candidate screening assistant',
prompt:
'Create a knowledge base from my job descriptions and hiring criteria, then build a workflow that takes uploaded resumes, evaluates candidates against the requirements, scores them on experience, skills, and culture fit, and populates a comparison table with a summary and recommendation for each.',

View File

@@ -1,15 +1,14 @@
'use client'
import { useState } from 'react'
import { type ComponentType, memo, type SVGProps } from 'react'
import Image from 'next/image'
import { ChevronDown } from '@/components/emcn/icons'
import { AgentIcon, ScheduleIcon, StartIcon } from '@/components/icons'
import type { Category, ModuleTag } from './consts'
import { CATEGORY_META, MODULE_META, TEMPLATES } from './consts'
import { CATEGORY_META, TEMPLATES } from './consts'
const FEATURED_TEMPLATES = TEMPLATES.filter((t) => t.featured)
const EXTRA_TEMPLATES = TEMPLATES.filter((t) => !t.featured)
/** Group non-featured templates by category, preserving category order. */
function getGroupedExtras() {
const groups: { category: Category; label: string; templates: typeof TEMPLATES }[] = []
const byCategory = new Map<Category, typeof TEMPLATES>()
@@ -38,72 +37,309 @@ function getGroupedExtras() {
const GROUPED_EXTRAS = getGroupedExtras()
function ModulePills({ modules }: { modules: ModuleTag[] }) {
const MINI_TABLE_DATA = [
['Sarah Chen', 'sarah@acme.co', 'Acme Inc', 'Qualified'],
['James Park', 'james@globex.io', 'Globex', 'New'],
['Maria Santos', 'maria@initech.com', 'Initech', 'Contacted'],
['Alex Kim', 'alex@umbrella.co', 'Umbrella', 'Qualified'],
['Emma Wilson', 'emma@stark.io', 'Stark Ind', 'New'],
] as const
const STATUS_DOT: Record<string, string> = {
Qualified: 'bg-emerald-400',
New: 'bg-blue-400',
Contacted: 'bg-amber-400',
}
const MINI_KB_DATA = [
['product-specs.pdf', '4.2 MB', '12.4k', 'Enabled'],
['eng-handbook.md', '1.8 MB', '8.2k', 'Enabled'],
['api-reference.json', '920 KB', '4.1k', 'Enabled'],
['release-notes.md', '340 KB', '2.8k', 'Enabled'],
['onboarding.pdf', '2.1 MB', '6.5k', 'Processing'],
] as const
const KB_BADGE: Record<string, string> = {
Enabled: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
Processing: 'bg-violet-500/15 text-violet-700 dark:text-violet-400',
}
interface WorkflowBlockDef {
color: string
name: string
icon: ComponentType<SVGProps<SVGSVGElement>>
rows: { title: string; value: string }[]
}
function PreviewTable() {
return (
<div className='flex flex-wrap gap-[4px]'>
{modules.map((mod) => (
<span
key={mod}
className='rounded-full bg-[var(--surface-3)] px-[6px] py-[1px] text-[11px] text-[var(--text-secondary)]'
>
{MODULE_META[mod].label}
</span>
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--surface-2)]'>
<div className='flex shrink-0 items-center border-[var(--border-1)] border-b bg-[var(--surface-3)]'>
{['Name', 'Email', 'Company', 'Status'].map((col) => (
<div key={col} className='flex flex-1 items-center px-[6px] py-[5px]'>
<span className='font-medium text-[7px] text-[var(--text-tertiary)]'>{col}</span>
</div>
))}
</div>
{MINI_TABLE_DATA.map((row, i) => (
<div key={i} className='flex items-center border-[var(--border-1)] border-b'>
{row.map((cell, j) => (
<div key={j} className='flex flex-1 items-center px-[6px] py-[2.5px]'>
{j === 3 ? (
<div className='flex items-center gap-[3px]'>
<div className={`h-[4px] w-[4px] shrink-0 rounded-full ${STATUS_DOT[cell]}`} />
<span className='text-[6.5px] text-[var(--text-tertiary)]'>{cell}</span>
</div>
) : (
<span
className={`truncate text-[7px] leading-[1.2] ${j === 0 ? 'font-medium text-[var(--text-body)]' : 'text-[var(--text-tertiary)]'}`}
>
{cell}
</span>
)}
</div>
))}
</div>
))}
</div>
)
}
function PreviewKnowledge() {
return (
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--surface-2)]'>
<div className='flex shrink-0 items-center border-[var(--border-1)] border-b bg-[var(--surface-3)]'>
{['Name', 'Size', 'Tokens', 'Status'].map((col) => (
<div key={col} className='flex flex-1 items-center px-[6px] py-[5px]'>
<span className='font-medium text-[7px] text-[var(--text-tertiary)]'>{col}</span>
</div>
))}
</div>
{MINI_KB_DATA.map((row, i) => (
<div key={i} className='flex items-center border-[var(--border-1)] border-b'>
<div className='flex flex-1 items-center px-[6px] py-[2.5px]'>
<span className='truncate font-medium text-[7px] text-[var(--text-body)] leading-[1.2]'>
{row[0]}
</span>
</div>
<div className='flex flex-1 items-center px-[6px] py-[2.5px]'>
<span className='text-[7px] text-[var(--text-tertiary)] leading-[1.2]'>{row[1]}</span>
</div>
<div className='flex flex-1 items-center px-[6px] py-[2.5px]'>
<span className='text-[7px] text-[var(--text-tertiary)] leading-[1.2]'>{row[2]}</span>
</div>
<div className='flex flex-1 items-center px-[6px] py-[2.5px]'>
<span
className={`inline-block rounded-full px-[4px] py-px text-[6px] ${KB_BADGE[row[3]]}`}
>
{row[3]}
</span>
</div>
</div>
))}
</div>
)
}
function PreviewFile() {
return (
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--surface-2)]'>
<div className='flex shrink-0 items-center gap-[4px] border-[var(--border-1)] border-b px-[10px] py-[5px]'>
<span className='text-[7px] text-[var(--text-tertiary)]'>Files</span>
<span className='text-[7px] text-[var(--text-tertiary)] opacity-40'>/</span>
<span className='font-medium text-[7px] text-[var(--text-body)]'>meeting-notes.md</span>
</div>
<div className='flex-1 overflow-hidden px-[10px] py-[6px]'>
<p className='font-semibold text-[8px] text-[var(--text-body)]'>Meeting Notes</p>
<p className='mt-[4px] font-medium text-[7px] text-[var(--text-body)]'>Action Items</p>
<p className='mt-[1px] text-[6.5px] text-[var(--text-tertiary)]'>
Review Q1 metrics with Sarah
</p>
<p className='text-[6.5px] text-[var(--text-tertiary)]'> Update API documentation</p>
<p className='text-[6.5px] text-[var(--text-tertiary)]'>
Schedule design review for v2.0
</p>
<p className='mt-[4px] font-medium text-[7px] text-[var(--text-body)]'>Discussion Points</p>
<p className='mt-[1px] text-[6.5px] text-[var(--text-tertiary)]'>
The team agreed to prioritize the new onboarding flow...
</p>
<p className='mt-[4px] font-medium text-[7px] text-[var(--text-body)]'>Next Steps</p>
<p className='mt-[1px] text-[6.5px] text-[var(--text-tertiary)]'>
Follow up with engineering on the API v2 migration.
</p>
</div>
</div>
)
}
const WorkflowMiniBlock = memo(function WorkflowMiniBlock({
color,
name,
icon: Icon,
rows,
}: WorkflowBlockDef) {
const hasRows = rows.length > 0
return (
<div className='w-[76px] rounded-[4px] border border-[var(--border-1)] bg-[var(--white)] dark:bg-[var(--surface-4)]'>
<div
className={`flex items-center gap-[4px] px-[5px] py-[3px] ${hasRows ? 'border-[var(--border-1)] border-b' : ''}`}
>
<div
className='flex h-[11px] w-[11px] shrink-0 items-center justify-center rounded-[3px]'
style={{ backgroundColor: color }}
>
<Icon className='h-[7px] w-[7px] text-white' />
</div>
<span className='truncate font-medium text-[6.5px] text-[var(--text-body)]'>{name}</span>
</div>
{rows.map((row) => (
<div key={row.title} className='flex items-center gap-[3px] px-[5px] py-[2px]'>
<span className='shrink-0 text-[5.5px] text-[var(--text-tertiary)]'>{row.title}</span>
<span className='ml-auto truncate text-[5.5px] text-[var(--text-body)]'>{row.value}</span>
</div>
))}
</div>
)
})
function buildWorkflowBlocks(template: (typeof TEMPLATES)[number]): WorkflowBlockDef[] {
const modules = template.modules
const toolName = template.title.split(' ')[0]
const hasAgent = modules.includes('agent')
const isScheduled = modules.includes('scheduled')
const starter: WorkflowBlockDef = isScheduled
? {
color: '#6366F1',
name: 'Schedule',
icon: ScheduleIcon,
rows: [{ title: 'Cron', value: '0 9 * * 1' }],
}
: {
color: '#2FB3FF',
name: 'Starter',
icon: StartIcon,
rows: [{ title: 'Trigger', value: 'Manual' }],
}
const agent: WorkflowBlockDef = {
color: '#802FFF',
name: 'Agent',
icon: AgentIcon,
rows: [{ title: 'Model', value: 'gpt-4o' }],
}
const tool: WorkflowBlockDef = {
color: '#3B3B3B',
name: toolName,
icon: template.icon,
rows: [{ title: 'Action', value: 'Run' }],
}
if (hasAgent) return [starter, agent, tool]
return [starter, tool]
}
const BLOCK_W = 76
const EDGE_W = 14
function PreviewWorkflow({ template }: { template: (typeof TEMPLATES)[number] }) {
const blocks = buildWorkflowBlocks(template)
const goesUp = template.title.charCodeAt(0) % 2 === 0
const twoBlock = blocks.length === 2
const offsets = twoBlock
? goesUp
? [-10, 10]
: [10, -10]
: goesUp
? [-12, 12, -12]
: [12, -12, 12]
const totalW = blocks.length * BLOCK_W + (blocks.length - 1) * EDGE_W
return (
<div className='flex h-full w-full items-center justify-center bg-[var(--surface-2)]'>
<div className='relative' style={{ width: totalW, height: 70 }}>
<svg
className='pointer-events-none absolute top-0 left-0 z-0'
width={totalW}
height={70}
fill='none'
style={{ overflow: 'visible' }}
>
{blocks.slice(1).map((_, i) => {
const x1 = i * (BLOCK_W + EDGE_W) + BLOCK_W
const y1 = 35 + offsets[i]
const x2 = (i + 1) * (BLOCK_W + EDGE_W)
const y2 = 35 + offsets[i + 1]
const midX = (x1 + x2) / 2
return (
<path
key={i}
d={`M${x1},${y1} C${midX},${y1} ${midX},${y2} ${x2},${y2}`}
className='stroke-[var(--text-icon)]'
strokeWidth={1}
opacity={0.3}
/>
)
})}
</svg>
{blocks.map((block, i) => {
const x = i * (BLOCK_W + EDGE_W)
const yCenter = 35 + offsets[i]
return (
<div key={block.name} className='absolute z-10' style={{ left: x, top: yCenter - 20 }}>
<WorkflowMiniBlock {...block} />
</div>
)
})}
</div>
</div>
)
}
function TemplatePreview({
modules,
template,
}: {
modules: ModuleTag[]
template: (typeof TEMPLATES)[number]
}) {
if (modules.includes('tables')) return <PreviewTable />
if (modules.includes('knowledge-base')) return <PreviewKnowledge />
if (modules.includes('files')) return <PreviewFile />
return <PreviewWorkflow template={template} />
}
interface TemplatePromptsProps {
onSelect: (prompt: string) => void
}
export function TemplatePrompts({ onSelect }: TemplatePromptsProps) {
const [expanded, setExpanded] = useState(false)
return (
<div className='flex flex-col gap-[24px]'>
{/* Featured grid */}
<div className='grid grid-cols-3 gap-[16px]'>
<div className='flex flex-col gap-[24px] lg:gap-[32px]'>
<div className='grid grid-cols-1 gap-[12px] md:grid-cols-2 md:gap-[16px] lg:grid-cols-3'>
{FEATURED_TEMPLATES.map((template) => (
<TemplateCard key={template.title} template={template} onSelect={onSelect} />
))}
</div>
{/* Expand / collapse */}
<button
type='button'
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
className='flex items-center justify-center gap-[6px] text-[13px] text-[var(--text-secondary)] transition-colors hover:text-[var(--text-body)]'
>
{expanded ? (
<>
Show less <ChevronDown className='h-[14px] w-[14px] rotate-180' />
</>
) : (
<>
More examples <ChevronDown className='h-[14px] w-[14px]' />
</>
)}
</button>
{/* Categorized extras */}
{expanded && (
<div className='flex flex-col gap-[32px]'>
{GROUPED_EXTRAS.map((group) => (
<div key={group.category} className='flex flex-col gap-[12px]'>
<h3 className='font-medium text-[13px] text-[var(--text-secondary)]'>
{group.label}
</h3>
<div className='grid grid-cols-3 gap-[16px]'>
{group.templates.map((template) => (
<TemplateCard key={template.title} template={template} onSelect={onSelect} />
))}
</div>
</div>
))}
{GROUPED_EXTRAS.map((group) => (
<div
key={group.category}
className='flex flex-col gap-[12px]'
style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 200px' }}
>
<h3 className='font-medium text-[13px] text-[var(--text-secondary)]'>{group.label}</h3>
<div className='grid grid-cols-1 gap-[12px] md:grid-cols-2 md:gap-[16px] lg:grid-cols-3'>
{group.templates.map((template) => (
<TemplateCard key={template.title} template={template} onSelect={onSelect} />
))}
</div>
</div>
)}
))}
</div>
)
}
@@ -113,7 +349,7 @@ interface TemplateCardProps {
onSelect: (prompt: string) => void
}
function TemplateCard({ template, onSelect }: TemplateCardProps) {
const TemplateCard = memo(function TemplateCard({ template, onSelect }: TemplateCardProps) {
const Icon = template.icon
return (
@@ -123,7 +359,7 @@ function TemplateCard({ template, onSelect }: TemplateCardProps) {
aria-label={`Select template: ${template.title}`}
className='group flex cursor-pointer flex-col text-left'
>
<div className='overflow-hidden rounded-[10px] border border-[var(--border-1)]'>
<div className='overflow-hidden rounded-[8px] border border-[var(--border-1)] transition-colors group-hover:bg-[var(--surface-2)]'>
<div className='relative h-[120px] w-full overflow-hidden'>
{template.image ? (
<Image
@@ -131,22 +367,17 @@ function TemplateCard({ template, onSelect }: TemplateCardProps) {
alt={template.title}
fill
unoptimized
className='object-cover transition-transform duration-300 group-hover:scale-105'
className='object-cover transition-transform duration-200 group-hover:scale-[1.02]'
/>
) : (
<div className='flex h-full w-full items-center justify-center bg-[var(--surface-3)] transition-colors group-hover:bg-[var(--surface-4)]'>
<Icon className='h-[32px] w-[32px] text-[var(--text-icon)] opacity-40' />
</div>
<TemplatePreview modules={template.modules} template={template} />
)}
</div>
<div className='flex flex-col gap-[4px] border-[var(--border-1)] border-t bg-[var(--white)] px-[10px] py-[6px] dark:bg-[var(--surface-4)]'>
<div className='flex items-center gap-[6px]'>
<Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
<span className='font-base text-[14px] text-[var(--text-body)]'>{template.title}</span>
</div>
<ModulePills modules={template.modules} />
<div className='flex items-center gap-[6px] border-[var(--border-1)] border-t bg-[var(--white)] px-[12px] py-[8px] transition-colors group-hover:bg-[var(--surface-2)] dark:bg-[var(--surface-4)]'>
<Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
<span className='text-[13px] text-[var(--text-body)]'>{template.title}</span>
</div>
</div>
</button>
)
}
})

View File

@@ -84,7 +84,7 @@ import { useAnimatedPlaceholder } from '../../hooks'
const TEXTAREA_BASE_CLASSES = cn(
'm-0 box-border h-auto min-h-[24px] w-full resize-none',
'overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent',
'overflow-y-auto overflow-x-hidden break-all border-0 bg-transparent',
'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]',
'text-transparent caret-[var(--text-primary)] outline-none',
'placeholder:font-[380] placeholder:text-[var(--text-subtle)]',
@@ -94,7 +94,7 @@ const TEXTAREA_BASE_CLASSES = cn(
const OVERLAY_CLASSES = cn(
'pointer-events-none absolute top-0 left-0 m-0 box-border h-auto w-full resize-none',
'overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-0 bg-transparent',
'overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all border-0 bg-transparent',
'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]',
'text-[var(--text-primary)] outline-none',
'[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'

View File

@@ -6,7 +6,7 @@ import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/type
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const USER_MESSAGE_CLASSES =
'whitespace-pre-wrap font-[430] font-[family-name:var(--font-inter)] text-[15px] text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'
'whitespace-pre-wrap break-all font-[430] font-[family-name:var(--font-inter)] text-[15px] text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'
interface UserMessageContentProps {
content: string

View File

@@ -3,7 +3,6 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { Skeleton } from '@/components/emcn'
import { PanelLeft } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { useSession } from '@/lib/auth/auth-client'
@@ -46,23 +45,6 @@ function FileAttachmentPill({ mediaType, filename }: FileAttachmentPillProps) {
)
}
const SKELETON_LINE_COUNT = 4
function ChatSkeleton({ children }: { children: React.ReactNode }) {
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='min-h-0 flex-1 overflow-hidden px-6 py-4'>
<div className='mx-auto max-w-[42rem] space-y-[10px] pt-3'>
{Array.from({ length: SKELETON_LINE_COUNT }).map((_, i) => (
<Skeleton key={i} className='h-[16px]' style={{ width: `${120 + (i % 4) * 48}px` }} />
))}
</div>
</div>
<div className='flex-shrink-0 px-[24px] pb-[16px]'>{children}</div>
</div>
)
}
interface HomeProps {
chatId?: string
}
@@ -77,6 +59,8 @@ export function Home({ chatId }: HomeProps = {}) {
const templateRef = useRef<HTMLDivElement>(null)
const baseInputHeightRef = useRef<number | null>(null)
const [isInputEntering, setIsInputEntering] = useState(false)
const createWorkflowFromLandingSeed = useCallback(
async (seed: LandingWorkflowSeed) => {
try {
@@ -150,7 +134,7 @@ export function Home({ chatId }: HomeProps = {}) {
const wasSendingRef = useRef(false)
const { isLoading: isLoadingHistory } = useChatHistory(chatId)
useChatHistory(chatId)
const { mutate: markRead } = useMarkTaskRead(workspaceId)
const [isResourceCollapsed, setIsResourceCollapsed] = useState(true)
@@ -177,7 +161,6 @@ export function Home({ chatId }: HomeProps = {}) {
const {
messages,
isSending,
isReconnecting,
sendMessage,
stopGeneration,
resolvedChatId,
@@ -245,11 +228,25 @@ export function Home({ chatId }: HomeProps = {}) {
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
const trimmed = text.trim()
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
if (initialViewInputRef.current) {
setIsInputEntering(true)
}
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
},
[sendMessage]
)
useEffect(() => {
const handler = (e: Event) => {
const message = (e as CustomEvent<{ message: string }>).detail?.message
if (message) sendMessage(message)
}
window.addEventListener('mothership-send-message', handler)
return () => window.removeEventListener('mothership-send-message', handler)
}, [sendMessage])
const handleContextAdd = useCallback(
(context: ChatContext) => {
let resourceType: MothershipResourceType | null = null
@@ -330,22 +327,7 @@ export function Home({ chatId }: HomeProps = {}) {
return () => ro.disconnect()
}, [hasMessages])
if (chatId && (isLoadingHistory || isReconnecting)) {
return (
<ChatSkeleton>
<UserInput
onSubmit={handleSubmit}
isSending={isSending}
onStopGeneration={stopGeneration}
isInitialView={false}
userId={session?.user?.id}
onContextAdd={handleContextAdd}
/>
</ChatSkeleton>
)
}
if (!hasMessages) {
if (!hasMessages && !chatId) {
return (
<div className='h-full overflow-y-auto bg-[var(--bg)] [scrollbar-gutter:stable]'>
<div className='flex min-h-full flex-col items-center justify-center px-[24px] pb-[2vh]'>
@@ -364,7 +346,10 @@ export function Home({ chatId }: HomeProps = {}) {
/>
</div>
</div>
<div ref={templateRef} className='-mt-[30vh] mx-auto w-full max-w-[42rem] pb-[32px]'>
<div
ref={templateRef}
className='-mt-[30vh] mx-auto w-full max-w-[68rem] px-[16px] pb-[32px] sm:px-[24px] lg:px-[40px]'
>
<TemplatePrompts onSelect={handleSubmit} />
</div>
</div>
@@ -409,7 +394,7 @@ export function Home({ chatId }: HomeProps = {}) {
})}
</div>
)}
<div className='max-w-[70%] rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2'>
<div className='max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2'>
<UserMessageContent content={msg.content} contexts={msg.contexts} />
</div>
</div>
@@ -442,7 +427,10 @@ export function Home({ chatId }: HomeProps = {}) {
</div>
</div>
<div className='flex-shrink-0 px-[24px] pb-[16px]'>
<div
className={`flex-shrink-0 px-[24px] pb-[16px]${isInputEntering ? ' animate-slide-in-bottom' : ''}`}
onAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
>
<div className='mx-auto max-w-[42rem]'>
<QueuedMessages
messageQueue={messageQueue}

View File

@@ -8,6 +8,10 @@ import {
reportManualRunToolStop,
} from '@/lib/copilot/client-sse/run-tool-execution'
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
import {
extractResourcesFromToolResult,
isResourceToolName,
} from '@/lib/copilot/resource-extraction'
import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types'
import { isWorkflowToolName } from '@/lib/copilot/workflow-tools'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
@@ -275,7 +279,11 @@ export function useChat(
const sendMessageRef = useRef<UseChatReturn['sendMessage']>(async () => {})
const processSSEStreamRef = useRef<
(reader: ReadableStreamDefaultReader<Uint8Array>, assistantId: string) => Promise<void>
(
reader: ReadableStreamDefaultReader<Uint8Array>,
assistantId: string,
expectedGen?: number
) => Promise<void>
>(async () => {})
const finalizeRef = useRef<(options?: { error?: boolean }) => void>(() => {})
@@ -375,7 +383,8 @@ export function useChat(
}
appliedChatIdRef.current = chatHistory.id
setMessages(chatHistory.messages.map(mapStoredMessage))
const mappedMessages = chatHistory.messages.map(mapStoredMessage)
setMessages(mappedMessages)
if (chatHistory.resources.length > 0) {
setResources(chatHistory.resources)
@@ -388,6 +397,7 @@ export function useChat(
}
if (activeStreamId && !sendingRef.current) {
abortControllerRef.current?.abort()
const gen = ++streamGenRef.current
const abortController = new AbortController()
abortControllerRef.current = abortController
@@ -457,7 +467,7 @@ export function useChat(
},
})
await processSSEStreamRef.current(combinedStream.getReader(), assistantId)
await processSSEStreamRef.current(combinedStream.getReader(), assistantId, gen)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
} finally {
@@ -485,7 +495,11 @@ export function useChat(
}, [activeResourceId, resources])
const processSSEStream = useCallback(
async (reader: ReadableStreamDefaultReader<Uint8Array>, assistantId: string) => {
async (
reader: ReadableStreamDefaultReader<Uint8Array>,
assistantId: string,
expectedGen?: number
) => {
const decoder = new TextDecoder()
let buffer = ''
const blocks: ContentBlock[] = []
@@ -501,16 +515,20 @@ export function useChat(
const ensureTextBlock = (): ContentBlock => {
const last = blocks[blocks.length - 1]
if (last?.type === 'text') return last
if (last?.type === 'text' && last.subagent === activeSubagent) return last
const b: ContentBlock = { type: 'text', content: '' }
blocks.push(b)
return b
}
const isStale = () => expectedGen !== undefined && streamGenRef.current !== expectedGen
const flush = () => {
if (isStale()) return
streamingBlocksRef.current = [...blocks]
const snapshot = { content: runningText, contentBlocks: [...blocks] }
setMessages((prev) => {
if (expectedGen !== undefined && streamGenRef.current !== expectedGen) return prev
const idx = prev.findIndex((m) => m.id === assistantId)
if (idx >= 0) {
return prev.map((m) => (m.id === assistantId ? { ...m, ...snapshot } : m))
@@ -520,6 +538,10 @@ export function useChat(
}
while (true) {
if (isStale()) {
reader.cancel().catch(() => {})
break
}
const { done, value } = await reader.read()
if (done) break
@@ -539,7 +561,6 @@ export function useChat(
}
logger.debug('SSE event received', parsed)
switch (parsed.type) {
case 'chat_id': {
if (parsed.chatId) {
@@ -588,6 +609,7 @@ export function useChat(
const tb = ensureTextBlock()
const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk
tb.content = (tb.content ?? '') + normalizedChunk
if (activeSubagent) tb.subagent = activeSubagent
runningText += normalizedChunk
lastContentSource = contentSource
streamingContentRef.current = runningText
@@ -621,7 +643,7 @@ export function useChat(
calledBy: activeSubagent,
},
})
if (name === 'read') {
if (name === 'read' || isResourceToolName(name)) {
const args = (data?.arguments ?? data?.input) as
| Record<string, unknown>
| undefined
@@ -647,6 +669,22 @@ export function useChat(
) {
clientExecutionStarted.add(id)
const args = data?.arguments ?? data?.input ?? {}
const targetWorkflowId =
typeof (args as Record<string, unknown>).workflowId === 'string'
? ((args as Record<string, unknown>).workflowId as string)
: useWorkflowRegistry.getState().activeWorkflowId
if (targetWorkflowId) {
const meta = useWorkflowRegistry.getState().workflows[targetWorkflowId]
const wasAdded = addResource({
type: 'workflow',
id: targetWorkflowId,
title: meta?.name ?? 'Workflow',
})
if (!wasAdded && activeResourceIdRef.current !== targetWorkflowId) {
setActiveResourceId(targetWorkflowId)
}
onResourceEventRef.current?.()
}
executeRunToolOnClient(id, name, args as Record<string, unknown>)
}
break
@@ -720,6 +758,17 @@ export function useChat(
})
}
}
if (tc.status === 'success' && isResourceToolName(tc.name)) {
const resources = extractResourcesFromToolResult(
tc.name,
toolArgsMap.get(id) as Record<string, unknown> | undefined,
tc.result?.output
)
for (const resource of resources) {
invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id)
}
}
}
break
@@ -785,6 +834,7 @@ export function useChat(
}
case 'subagent_end': {
activeSubagent = undefined
blocks.push({ type: 'subagent_end' })
flush()
break
}
@@ -944,15 +994,15 @@ export function useChat(
content: message,
...(storedAttachments && { fileAttachments: storedAttachments }),
}
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(chatIdRef.current), (old) =>
old
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(chatIdRef.current), (old) => {
return old
? {
...old,
messages: [...old.messages, cachedUserMsg],
activeStreamId: userMessageId,
}
: undefined
)
})
}
const userAttachments = storedAttachments?.map(toDisplayAttachment)
@@ -1018,7 +1068,7 @@ export function useChat(
if (!response.body) throw new Error('No response body')
await processSSEStream(response.body.getReader(), assistantId)
await processSSEStream(response.body.getReader(), assistantId, gen)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
setError(err instanceof Error ? err.message : 'Failed to send message')
@@ -1038,6 +1088,14 @@ export function useChat(
})
const stopGeneration = useCallback(async () => {
if (sendingRef.current && !chatIdRef.current) {
const start = Date.now()
while (!chatIdRef.current && sendingRef.current && Date.now() - start < 3000) {
await new Promise((r) => setTimeout(r, 50))
}
if (!chatIdRef.current) return
}
if (sendingRef.current) {
await persistPartialResponse()
}
@@ -1149,6 +1207,8 @@ export function useChat(
useEffect(() => {
return () => {
abortControllerRef.current?.abort()
abortControllerRef.current = null
streamGenRef.current++
sendingRef.current = false
}

View File

@@ -129,11 +129,18 @@ export type ToolPhase =
export type ToolCallStatus = 'executing' | 'success' | 'error' | 'cancelled'
export interface ToolCallResult {
success: boolean
output?: unknown
error?: string
}
export interface ToolCallData {
id: string
toolName: string
displayTitle: string
status: ToolCallStatus
result?: ToolCallResult
}
export interface ToolCallInfo {
@@ -155,6 +162,7 @@ export type ContentBlockType =
| 'text'
| 'tool_call'
| 'subagent'
| 'subagent_end'
| 'subagent_text'
| 'options'
| 'stopped'
@@ -162,6 +170,7 @@ export type ContentBlockType =
export interface ContentBlock {
type: ContentBlockType
content?: string
subagent?: string
toolCall?: ToolCallInfo
options?: OptionItem[]
}

View File

@@ -486,7 +486,7 @@ export function ScheduleModal({ open, onOpenChange, workspaceId, schedule }: Sch
onValueChange={(value) => setLifecycle(value as 'persistent' | 'until_complete')}
>
<ButtonGroupItem value='persistent'>Recurring</ButtonGroupItem>
<ButtonGroupItem value='until_complete'>Until Complete</ButtonGroupItem>
<ButtonGroupItem value='until_complete'>Number of runs</ButtonGroupItem>
</ButtonGroup>
</div>

View File

@@ -3,13 +3,14 @@
import dynamic from 'next/dynamic'
import { useSearchParams } from 'next/navigation'
import { Skeleton } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { AdminSkeleton } from '@/app/workspace/[workspaceId]/settings/components/admin/admin-skeleton'
import { ApiKeysSkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton'
import { BYOKSkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton'
import { CopilotSkeleton } from '@/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton'
import { CredentialSetsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets-skeleton'
import { CredentialsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton'
import { CustomToolsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton'
import { DebugSkeleton } from '@/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton'
import { GeneralSkeleton } from '@/app/workspace/[workspaceId]/settings/components/general/general-skeleton'
import { InboxSkeleton } from '@/app/workspace/[workspaceId]/settings/components/inbox/inbox-skeleton'
import { McpSkeleton } from '@/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton'
@@ -130,10 +131,10 @@ const Inbox = dynamic(
import('@/app/workspace/[workspaceId]/settings/components/inbox/inbox').then((m) => m.Inbox),
{ loading: () => <InboxSkeleton /> }
)
const Debug = dynamic(
const Admin = dynamic(
() =>
import('@/app/workspace/[workspaceId]/settings/components/debug/debug').then((m) => m.Debug),
{ loading: () => <DebugSkeleton /> }
import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin),
{ loading: () => <AdminSkeleton /> }
)
const RecentlyDeleted = dynamic(
() =>
@@ -157,9 +158,15 @@ interface SettingsPageProps {
export function SettingsPage({ section }: SettingsPageProps) {
const searchParams = useSearchParams()
const mcpServerId = searchParams.get('mcpServerId')
const { data: session, isPending: sessionLoading } = useSession()
const isAdminRole = session?.user?.role === 'admin'
const effectiveSection =
!isBillingEnabled && (section === 'subscription' || section === 'team') ? 'general' : section
!isBillingEnabled && (section === 'subscription' || section === 'team')
? 'general'
: section === 'admin' && !sessionLoading && !isAdminRole
? 'general'
: section
const label =
allNavigationItems.find((item) => item.id === effectiveSection)?.label ?? effectiveSection
@@ -185,7 +192,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
{effectiveSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
{effectiveSection === 'inbox' && <Inbox />}
{effectiveSection === 'recently-deleted' && <RecentlyDeleted />}
{effectiveSection === 'debug' && <Debug />}
{effectiveSection === 'admin' && <Admin />}
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { Skeleton } from '@/components/emcn'
export function AdminSkeleton() {
return (
<div className='flex h-full flex-col gap-[24px]'>
<div className='flex items-center justify-between'>
<Skeleton className='h-[14px] w-[120px]' />
<Skeleton className='h-[20px] w-[36px] rounded-full' />
</div>
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-[14px] w-[340px]' />
<div className='flex gap-[8px]'>
<Skeleton className='h-9 flex-1 rounded-[6px]' />
<Skeleton className='h-9 w-[80px] rounded-[6px]' />
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-[14px] w-[120px]' />
<Skeleton className='h-[200px] w-full rounded-[8px]' />
</div>
</div>
)
}

View File

@@ -0,0 +1,326 @@
'use client'
import { useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { Badge, Button, Input as EmcnInput, Label, Skeleton, Switch } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import {
useAdminUsers,
useBanUser,
useSetUserRole,
useUnbanUser,
} from '@/hooks/queries/admin-users'
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import { useImportWorkflow } from '@/hooks/queries/workflows'
const PAGE_SIZE = 20 as const
export function Admin() {
const params = useParams()
const workspaceId = params?.workspaceId as string
const { data: session } = useSession()
const { data: settings } = useGeneralSettings()
const updateSetting = useUpdateGeneralSetting()
const importWorkflow = useImportWorkflow()
const setUserRole = useSetUserRole()
const banUser = useBanUser()
const unbanUser = useUnbanUser()
const [workflowId, setWorkflowId] = useState('')
const [usersOffset, setUsersOffset] = useState(0)
const [usersEnabled, setUsersEnabled] = useState(false)
const [banUserId, setBanUserId] = useState<string | null>(null)
const [banReason, setBanReason] = useState('')
const {
data: usersData,
isLoading: usersLoading,
error: usersError,
refetch: refetchUsers,
} = useAdminUsers(usersOffset, PAGE_SIZE, usersEnabled)
const totalPages = useMemo(
() => Math.ceil((usersData?.total ?? 0) / PAGE_SIZE),
[usersData?.total]
)
const currentPage = useMemo(() => Math.floor(usersOffset / PAGE_SIZE) + 1, [usersOffset])
const handleSuperUserModeToggle = async (checked: boolean) => {
if (checked !== settings?.superUserModeEnabled && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'superUserModeEnabled', value: checked })
}
}
const handleImport = () => {
if (!workflowId.trim()) return
importWorkflow.mutate(
{ workflowId: workflowId.trim(), targetWorkspaceId: workspaceId },
{ onSuccess: () => setWorkflowId('') }
)
}
const handleLoadUsers = () => {
if (usersEnabled) {
refetchUsers()
} else {
setUsersEnabled(true)
}
}
const pendingUserIds = useMemo(() => {
const ids = new Set<string>()
if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId)
ids.add((setUserRole.variables as { userId: string }).userId)
if (banUser.isPending && (banUser.variables as { userId?: string })?.userId)
ids.add((banUser.variables as { userId: string }).userId)
if (unbanUser.isPending && (unbanUser.variables as { userId?: string })?.userId)
ids.add((unbanUser.variables as { userId: string }).userId)
return ids
}, [
setUserRole.isPending,
setUserRole.variables,
banUser.isPending,
banUser.variables,
unbanUser.isPending,
unbanUser.variables,
])
return (
<div className='flex h-full flex-col gap-[24px]'>
<div className='flex items-center justify-between'>
<Label htmlFor='super-user-mode'>Super admin mode</Label>
<Switch
id='super-user-mode'
checked={settings?.superUserModeEnabled ?? false}
onCheckedChange={handleSuperUserModeToggle}
/>
</div>
<div className='h-px bg-[var(--border-secondary)]' />
<div className='flex flex-col gap-[8px]'>
<p className='text-[14px] text-[var(--text-secondary)]'>
Import a workflow by ID along with its associated copilot chats.
</p>
<div className='flex gap-[8px]'>
<EmcnInput
value={workflowId}
onChange={(e) => {
setWorkflowId(e.target.value)
importWorkflow.reset()
}}
placeholder='Enter workflow ID'
disabled={importWorkflow.isPending}
/>
<Button
variant='primary'
onClick={handleImport}
disabled={importWorkflow.isPending || !workflowId.trim()}
>
{importWorkflow.isPending ? 'Importing...' : 'Import'}
</Button>
</div>
{importWorkflow.error && (
<p className='text-[13px] text-[var(--text-error)]'>{importWorkflow.error.message}</p>
)}
{importWorkflow.isSuccess && (
<p className='text-[13px] text-[var(--text-secondary)]'>
Workflow imported successfully (new ID: {importWorkflow.data.newWorkflowId},{' '}
{importWorkflow.data.copilotChatsImported ?? 0} copilot chats imported)
</p>
)}
</div>
<div className='h-px bg-[var(--border-secondary)]' />
<div className='flex flex-col gap-[12px]'>
<div className='flex items-center justify-between'>
<p className='font-medium text-[14px] text-[var(--text-primary)]'>User Management</p>
<Button variant='active' onClick={handleLoadUsers} disabled={usersLoading}>
{usersLoading ? 'Loading...' : usersEnabled ? 'Refresh' : 'Load Users'}
</Button>
</div>
{usersError && (
<p className='text-[13px] text-[var(--text-error)]'>
{usersError instanceof Error ? usersError.message : 'Failed to fetch users'}
</p>
)}
{(setUserRole.error || banUser.error || unbanUser.error) && (
<p className='text-[13px] text-[var(--text-error)]'>
{(setUserRole.error || banUser.error || unbanUser.error)?.message ??
'Action failed. Please try again.'}
</p>
)}
{usersLoading && !usersData && (
<div className='flex flex-col gap-[8px]'>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className='h-[48px] w-full rounded-[6px]' />
))}
</div>
)}
{usersData && (
<>
<div className='flex flex-col gap-[2px] rounded-[8px] border border-[var(--border-secondary)]'>
<div className='flex items-center gap-[12px] border-[var(--border-secondary)] border-b px-[12px] py-[8px] text-[12px] text-[var(--text-tertiary)]'>
<span className='w-[200px]'>Name</span>
<span className='flex-1'>Email</span>
<span className='w-[80px]'>Role</span>
<span className='w-[80px]'>Status</span>
<span className='w-[180px] text-right'>Actions</span>
</div>
{usersData.users.length === 0 && (
<div className='px-[12px] py-[16px] text-center text-[13px] text-[var(--text-tertiary)]'>
No users found.
</div>
)}
{usersData.users.map((u) => (
<div
key={u.id}
className={cn(
'flex items-center gap-[12px] px-[12px] py-[8px] text-[13px]',
'border-[var(--border-secondary)] border-b last:border-b-0'
)}
>
<span className='w-[200px] truncate text-[var(--text-primary)]'>
{u.name || '—'}
</span>
<span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span>
<span className='w-[80px]'>
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>{u.role || 'user'}</Badge>
</span>
<span className='w-[80px]'>
{u.banned ? (
<Badge variant='red'>Banned</Badge>
) : (
<Badge variant='green'>Active</Badge>
)}
</span>
<span className='flex w-[180px] justify-end gap-[4px]'>
{u.id !== session?.user?.id && (
<>
<Button
variant='active'
className='h-[28px] px-[8px] text-[12px]'
onClick={() => {
setUserRole.reset()
setUserRole.mutate({
userId: u.id,
role: u.role === 'admin' ? 'user' : 'admin',
})
}}
disabled={pendingUserIds.has(u.id)}
>
{u.role === 'admin' ? 'Demote' : 'Promote'}
</Button>
{u.banned ? (
<Button
variant='active'
className='h-[28px] px-[8px] text-[12px]'
onClick={() => {
unbanUser.reset()
unbanUser.mutate({ userId: u.id })
}}
disabled={pendingUserIds.has(u.id)}
>
Unban
</Button>
) : banUserId === u.id ? (
<div className='flex gap-[4px]'>
<EmcnInput
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
placeholder='Reason (optional)'
className='h-[28px] w-[120px] text-[12px]'
/>
<Button
variant='primary'
className='h-[28px] px-[8px] text-[12px]'
onClick={() => {
banUser.reset()
banUser.mutate(
{
userId: u.id,
...(banReason.trim() ? { banReason: banReason.trim() } : {}),
},
{
onSuccess: () => {
setBanUserId(null)
setBanReason('')
},
}
)
}}
disabled={pendingUserIds.has(u.id)}
>
Confirm
</Button>
<Button
variant='active'
className='h-[28px] px-[8px] text-[12px]'
onClick={() => {
setBanUserId(null)
setBanReason('')
}}
>
Cancel
</Button>
</div>
) : (
<Button
variant='active'
className='h-[28px] px-[8px] text-[12px] text-[var(--text-error)]'
onClick={() => {
setBanUserId(u.id)
setBanReason('')
}}
disabled={pendingUserIds.has(u.id)}
>
Ban
</Button>
)}
</>
)}
</span>
</div>
))}
</div>
{totalPages > 1 && (
<div className='flex items-center justify-between text-[13px] text-[var(--text-secondary)]'>
<span>
Page {currentPage} of {totalPages} ({usersData.total} users)
</span>
<div className='flex gap-[4px]'>
<Button
variant='active'
className='h-[28px] px-[8px] text-[12px]'
onClick={() => setUsersOffset((prev) => prev - PAGE_SIZE)}
disabled={usersOffset === 0 || usersLoading}
>
Previous
</Button>
<Button
variant='active'
className='h-[28px] px-[8px] text-[12px]'
onClick={() => setUsersOffset((prev) => prev + PAGE_SIZE)}
disabled={usersOffset + PAGE_SIZE >= (usersData?.total ?? 0) || usersLoading}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
)
}

View File

@@ -1,17 +0,0 @@
import { Skeleton } from '@/components/emcn'
/**
* Skeleton for the Debug section shown during dynamic import loading.
* Matches the layout: description text + input/button row.
*/
export function DebugSkeleton() {
return (
<div className='flex h-full flex-col gap-[18px]'>
<Skeleton className='h-[14px] w-[340px]' />
<div className='flex gap-[8px]'>
<Skeleton className='h-9 flex-1 rounded-[6px]' />
<Skeleton className='h-9 w-[80px] rounded-[6px]' />
</div>
</div>
)
}

View File

@@ -1,75 +0,0 @@
'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import { Button, Input as EmcnInput } from '@/components/emcn'
import { DebugSkeleton } from '@/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton'
import { useImportWorkflow } from '@/hooks/queries/workflows'
/**
* Debug settings component for superusers.
* Allows importing workflows by ID for debugging purposes.
*/
export function Debug() {
const params = useParams()
const workspaceId = params?.workspaceId as string
const [workflowId, setWorkflowId] = useState('')
const importWorkflow = useImportWorkflow()
const handleImport = () => {
if (!workflowId.trim()) return
importWorkflow.mutate(
{
workflowId: workflowId.trim(),
targetWorkspaceId: workspaceId,
},
{
onSuccess: () => {
setWorkflowId('')
},
}
)
}
return (
<div className='flex h-full flex-col gap-[18px]'>
<p className='text-[14px] text-[var(--text-secondary)]'>
Import a workflow by ID along with its associated copilot chats.
</p>
<div className='flex gap-[8px]'>
<EmcnInput
value={workflowId}
onChange={(e) => {
setWorkflowId(e.target.value)
importWorkflow.reset()
}}
placeholder='Enter workflow ID'
disabled={importWorkflow.isPending}
/>
<Button
variant='primary'
onClick={handleImport}
disabled={importWorkflow.isPending || !workflowId.trim()}
>
{importWorkflow.isPending ? 'Importing...' : 'Import'}
</Button>
</div>
{importWorkflow.isPending && <DebugSkeleton />}
{importWorkflow.error && (
<p className='text-[13px] text-[var(--text-error)]'>{importWorkflow.error.message}</p>
)}
{importWorkflow.isSuccess && (
<p className='text-[13px] text-[var(--text-secondary)]'>
Workflow imported successfully (new ID: {importWorkflow.data.newWorkflowId},{' '}
{importWorkflow.data.copilotChatsImported ?? 0} copilot chats imported)
</p>
)}
</div>
)
}

View File

@@ -28,7 +28,6 @@ import { useBrandConfig } from '@/ee/whitelabeling'
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import {
useResetPassword,
useSuperUserStatus,
useUpdateUserProfile,
useUserProfile,
} from '@/hooks/queries/user-profile'
@@ -66,9 +65,6 @@ export function General() {
const isTrainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))
const isAuthDisabled = session?.user?.id === ANONYMOUS_USER_ID
const { data: superUserData } = useSuperUserStatus(session?.user?.id)
const isSuperUser = superUserData?.isSuperUser ?? false
const [name, setName] = useState(profile?.name || '')
const [isEditingName, setIsEditingName] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
@@ -227,12 +223,6 @@ export function General() {
}
}
const handleSuperUserModeToggle = async (checked: boolean) => {
if (checked !== settings?.superUserModeEnabled && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'superUserModeEnabled', value: checked })
}
}
const handleTelemetryToggle = async (checked: boolean) => {
if (checked !== settings?.telemetryEnabled && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'telemetryEnabled', value: checked })
@@ -458,17 +448,6 @@ export function General() {
</div>
)}
{isSuperUser && (
<div className='flex items-center justify-between'>
<Label htmlFor='super-user-mode'>Super admin mode</Label>
<Switch
id='super-user-mode'
checked={settings?.superUserModeEnabled ?? true}
onCheckedChange={handleSuperUserModeToggle}
/>
</div>
)}
<div className='mt-auto flex items-center gap-[8px]'>
{!isAuthDisabled && (
<>

View File

@@ -1,11 +1,11 @@
import {
BookOpen,
Bug,
Card,
Connections,
HexSimple,
Key,
KeySquare,
Lock,
LogIn,
Mail,
Send,
@@ -40,7 +40,7 @@ export type SettingsSection =
| 'workflow-mcp-servers'
| 'inbox'
| 'docs'
| 'debug'
| 'admin'
| 'recently-deleted'
export type NavigationSection =
@@ -62,6 +62,7 @@ export interface NavigationItem {
requiresHosted?: boolean
selfHostedOverride?: boolean
requiresSuperUser?: boolean
requiresAdminRole?: boolean
externalUrl?: string
}
@@ -165,10 +166,10 @@ export const allNavigationItems: NavigationItem[] = [
externalUrl: 'https://docs.sim.ai',
},
{
id: 'debug',
label: 'Debug',
icon: Bug,
id: 'admin',
label: 'Admin',
icon: Lock,
section: 'superuser',
requiresSuperUser: true,
requiresAdminRole: true,
},
]

View File

@@ -7,7 +7,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Copy, Database, Download, Pencil, Trash } from '@/components/emcn/icons'
import { Copy, Database, Pencil, Trash } from '@/components/emcn/icons'
interface TableContextMenuProps {
isOpen: boolean
@@ -17,10 +17,8 @@ interface TableContextMenuProps {
onDelete?: () => void
onViewSchema?: () => void
onRename?: () => void
onExportCsv?: () => void
disableDelete?: boolean
disableRename?: boolean
disableExportCsv?: boolean
menuRef?: React.RefObject<HTMLDivElement | null>
}
@@ -32,10 +30,8 @@ export function TableContextMenu({
onDelete,
onViewSchema,
onRename,
onExportCsv,
disableDelete = false,
disableRename = false,
disableExportCsv = false,
}: TableContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
@@ -71,22 +67,14 @@ export function TableContextMenu({
Rename
</DropdownMenuItem>
)}
{(onViewSchema || onRename) && (onCopyId || onExportCsv || onDelete) && (
<DropdownMenuSeparator />
)}
{(onViewSchema || onRename) && (onCopyId || onDelete) && <DropdownMenuSeparator />}
{onCopyId && (
<DropdownMenuItem onSelect={onCopyId}>
<Copy />
Copy ID
</DropdownMenuItem>
)}
{onExportCsv && (
<DropdownMenuItem disabled={disableExportCsv} onSelect={onExportCsv}>
<Download />
Export CSV
</DropdownMenuItem>
)}
{(onCopyId || onExportCsv) && onDelete && <DropdownMenuSeparator />}
{onCopyId && onDelete && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
<Trash />

View File

@@ -14,7 +14,6 @@ import {
Upload,
} from '@/components/emcn'
import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons'
import { escapeCsvValue } from '@/lib/copilot/orchestrator/sse/handlers/tool-execution'
import type { TableDefinition } from '@/lib/table'
import { generateUniqueTableName } from '@/lib/table/constants'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
@@ -219,52 +218,6 @@ export function Tables() {
[workspaceId, router]
)
const handleExportCsv = useCallback(async () => {
if (!activeTable || !workspaceId) return
closeRowContextMenu()
try {
const tableRes = await fetch(
`/api/table/${activeTable.id}?workspaceId=${encodeURIComponent(workspaceId)}`
)
if (!tableRes.ok) throw new Error('Failed to fetch table')
const tableJson = await tableRes.json()
const table = tableJson.data?.table ?? tableJson.table
const rowsRes = await fetch(
`/api/table/${activeTable.id}/rows?workspaceId=${encodeURIComponent(workspaceId)}&limit=1000&offset=0`
)
if (!rowsRes.ok) throw new Error('Failed to fetch rows')
const rowsJson = await rowsRes.json()
const tableRows = rowsJson.data?.rows ?? rowsJson.rows ?? []
const cols = table?.schema?.columns ?? []
if (cols.length === 0) {
toast.error('Table has no columns to export')
return
}
const header = cols.map((col: { name: string }) => escapeCsvValue(col.name)).join(',')
const dataRows = tableRows.map((row: { data: Record<string, unknown> }) =>
cols.map((col: { name: string }) => escapeCsvValue(row.data[col.name])).join(',')
)
const csv = [header, ...dataRows].join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${activeTable.name}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (err) {
logger.error('Failed to export table as CSV:', err)
toast.error('Failed to export table as CSV')
}
}, [activeTable, workspaceId, closeRowContextMenu])
const handleListUploadCsv = useCallback(() => {
csvInputRef.current?.click()
closeListContextMenu()
@@ -356,7 +309,6 @@ export function Tables() {
onCopyId={() => {
if (activeTable) navigator.clipboard.writeText(activeTable.id)
}}
onExportCsv={handleExportCsv}
onDelete={() => setIsDeleteDialogOpen(true)}
disableDelete={userPermissions.canEdit !== true}
disableRename={userPermissions.canEdit !== true}

View File

@@ -44,9 +44,9 @@ export default async function TemplatesPage({ params }: TemplatesPageProps) {
redirect(`/workspace/${workspaceId}`)
}
// Determine effective super user (DB flag AND UI mode enabled)
// Determine effective super user (admin role AND UI mode enabled)
const currentUser = await db
.select({ isSuperUser: user.isSuperUser })
.select({ role: user.role })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
@@ -56,8 +56,8 @@ export default async function TemplatesPage({ params }: TemplatesPageProps) {
.where(eq(settings.userId, session.user.id))
.limit(1)
const isSuperUser = currentUser[0]?.isSuperUser || false
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true
const isSuperUser = currentUser[0]?.role === 'admin'
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? false
const effectiveSuperUser = isSuperUser && superUserModeEnabled
// Load templates from database

View File

@@ -9,6 +9,7 @@ import {
type Notification,
type NotificationAction,
openCopilotWithMessage,
sendMothershipMessage,
useNotificationStore,
} from '@/stores/notifications'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -81,7 +82,11 @@ function CountdownRing({ onPause }: { onPause: () => void }) {
* Workflow error notifications auto-dismiss after {@link AUTO_DISMISS_MS}ms with a countdown
* ring. Clicking the ring pauses all timers until the notification stack clears.
*/
export const Notifications = memo(function Notifications() {
interface NotificationsProps {
embedded?: boolean
}
export const Notifications = memo(function Notifications({ embedded }: NotificationsProps) {
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const allNotifications = useNotificationStore((state) => state.notifications)
@@ -112,7 +117,11 @@ export const Notifications = memo(function Notifications() {
switch (action.type) {
case 'copilot':
openCopilotWithMessage(action.message)
if (embedded) {
sendMothershipMessage(action.message)
} else {
openCopilotWithMessage(action.message)
}
break
case 'refresh':
window.location.reload()
@@ -133,7 +142,7 @@ export const Notifications = memo(function Notifications() {
})
}
},
[removeNotification]
[embedded, removeNotification]
)
useRegisterGlobalCommands(() =>
@@ -281,7 +290,9 @@ export const Notifications = memo(function Notifications() {
onClick={() => executeAction(notification.id, notification.action!)}
className='w-full rounded-[5px] px-[8px] py-[4px] font-medium text-[12px]'
>
{ACTION_LABELS[notification.action!.type] ?? 'Take action'}
{embedded && notification.action!.type === 'copilot'
? 'Fix in Mothership'
: (ACTION_LABELS[notification.action!.type] ?? 'Take action')}
</Button>
)}
</div>

View File

@@ -4,6 +4,7 @@ import { useReactFlow } from 'reactflow'
import type { AutoLayoutOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils'
import { applyAutoLayoutAndUpdateStore as applyAutoLayoutStandalone } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils'
import { useSnapToGridSize } from '@/hooks/queries/general-settings'
import type { CanvasViewportOptions } from '@/hooks/use-canvas-viewport'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
export type { AutoLayoutOptions }
@@ -18,9 +19,9 @@ const logger = createLogger('useAutoLayout')
*
* Note: This hook requires a ReactFlowProvider ancestor.
*/
export function useAutoLayout(workflowId: string | null) {
export function useAutoLayout(workflowId: string | null, options?: CanvasViewportOptions) {
const reactFlowInstance = useReactFlow()
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance, options)
const snapToGridSize = useSnapToGridSize()
const applyAutoLayoutAndUpdateStore = useCallback(

View File

@@ -208,7 +208,8 @@ const reactFlowStyles = [
'[&_.react-flow__node-subflowNode.selected]:!shadow-none',
].join(' ')
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
const embeddedFitViewOptions = { padding: 0.15, maxZoom: 0.85, minZoom: 0.35 } as const
const embeddedFitViewOptions = { padding: 0.15, maxZoom: 0.85, minZoom: 0.1 } as const
const embeddedResizeFitViewOptions = { ...embeddedFitViewOptions, duration: 0 } as const
const reactFlowProOptions = { hideAttribution: true } as const
/**
@@ -244,7 +245,10 @@ const WorkflowContent = React.memo(
const [potentialParentId, setPotentialParentId] = useState<string | null>(null)
const [selectedEdges, setSelectedEdges] = useState<SelectedEdgesMap>(new Map())
const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false)
const canvasContainerRef = useRef<HTMLDivElement>(null)
const selectedIdsRef = useRef<string[] | null>(null)
const embeddedFitFrameRef = useRef<number | null>(null)
const hasCompletedInitialEmbeddedFitRef = useRef(false)
const canvasMode = useCanvasModeStore((state) => state.mode)
const isHandMode = embedded ? true : canvasMode === 'hand'
const { handleCanvasMouseDown, selectionProps } = useShiftSelectionLock({ isHandMode })
@@ -260,7 +264,9 @@ const WorkflowContent = React.memo(
const router = useRouter()
const reactFlowInstance = useReactFlow()
const { screenToFlowPosition, getNodes, setNodes, getIntersectingNodes } = reactFlowInstance
const { fitViewToBounds, getViewportCenter } = useCanvasViewport(reactFlowInstance)
const { fitViewToBounds, getViewportCenter } = useCanvasViewport(reactFlowInstance, {
embedded,
})
const { emitCursorUpdate } = useSocket()
useDynamicHandleRefresh()
@@ -373,6 +379,34 @@ const WorkflowContent = React.memo(
]
)
const scheduleEmbeddedFit = useCallback(() => {
if (!embedded || !isWorkflowReady) return
if (embeddedFitFrameRef.current !== null) {
cancelAnimationFrame(embeddedFitFrameRef.current)
}
embeddedFitFrameRef.current = requestAnimationFrame(() => {
embeddedFitFrameRef.current = null
const container = canvasContainerRef.current
if (!container) return
const rect = container.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return
const nodes = reactFlowInstance.getNodes()
if (nodes.length > 0) {
void reactFlowInstance.fitView(embeddedResizeFitViewOptions)
}
if (!hasCompletedInitialEmbeddedFitRef.current) {
hasCompletedInitialEmbeddedFitRef.current = true
setIsCanvasReady(true)
}
})
}, [embedded, isWorkflowReady, reactFlowInstance])
const {
getNodeDepth,
getNodeAbsolutePosition,
@@ -441,7 +475,9 @@ const WorkflowContent = React.memo(
[]
)
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null, {
embedded,
})
const isWorkflowEmpty = useMemo(() => Object.keys(blocks).length === 0, [blocks])
@@ -3750,10 +3786,46 @@ const WorkflowContent = React.memo(
activeWorkflowId,
])
useEffect(() => {
if (!embedded || !isWorkflowReady) {
return
}
const container = canvasContainerRef.current
if (!container) {
return
}
scheduleEmbeddedFit()
const resizeObserver = new ResizeObserver(() => {
scheduleEmbeddedFit()
})
resizeObserver.observe(container)
return () => {
resizeObserver.disconnect()
if (embeddedFitFrameRef.current !== null) {
cancelAnimationFrame(embeddedFitFrameRef.current)
embeddedFitFrameRef.current = null
}
}
}, [embedded, isWorkflowReady, scheduleEmbeddedFit])
useEffect(() => {
if (!embedded || !isWorkflowReady) {
return
}
scheduleEmbeddedFit()
}, [blocksStructureHash, embedded, isWorkflowReady, scheduleEmbeddedFit])
return (
<div className='flex h-full w-full overflow-hidden'>
<div className='flex min-w-0 flex-1 flex-col'>
<div className='relative flex-1 overflow-hidden'>
<div ref={canvasContainerRef} className='relative flex-1 overflow-hidden'>
{!isWorkflowReady && (
<div className='absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)]'>
<div
@@ -3791,8 +3863,12 @@ const WorkflowContent = React.memo(
onDrop={effectivePermissions.canEdit ? onDrop : undefined}
onDragOver={effectivePermissions.canEdit ? onDragOver : undefined}
onInit={(instance) => {
if (embedded) {
return
}
requestAnimationFrame(() => {
instance.fitView(embedded ? embeddedFitViewOptions : reactFlowFitViewOptions)
instance.fitView(reactFlowFitViewOptions)
setIsCanvasReady(true)
})
}}
@@ -3922,7 +3998,7 @@ const WorkflowContent = React.memo(
</>
)}
{!embedded && <Notifications />}
<Notifications embedded={embedded} />
{!embedded && isWorkflowReady && isWorkflowEmpty && effectivePermissions.canEdit && (
<CommandList />

View File

@@ -20,7 +20,6 @@ import { prefetchWorkspaceCredentials } from '@/hooks/queries/credentials'
import { prefetchGeneralSettings, useGeneralSettings } from '@/hooks/queries/general-settings'
import { useOrganizations } from '@/hooks/queries/organization'
import { prefetchSubscriptionData, useSubscriptionData } from '@/hooks/queries/subscription'
import { useSuperUserStatus } from '@/hooks/queries/user-profile'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -49,7 +48,6 @@ export function SettingsSidebar({
staleTime: 5 * 60 * 1000,
})
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
const { data: superUserData } = useSuperUserStatus(session?.user?.id)
const activeOrganization = organizationsData?.activeOrganization
const { config: permissionConfig } = usePermissionConfig()
@@ -65,7 +63,7 @@ export function SettingsSidebar({
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
const hasEnterprisePlan = subscriptionStatus.isEnterprise
const isSuperUser = superUserData?.isSuperUser ?? false
const isSuperUser = session?.user?.role === 'admin'
const isSSOProviderOwner = useMemo(() => {
if (isHosted) return null
@@ -123,6 +121,10 @@ export function SettingsSidebar({
return false
}
if (item.requiresAdminRole && !isSuperUser) {
return false
}
return true
})
}, [
@@ -243,11 +245,9 @@ export function SettingsSidebar({
return (
<div key={key} className='flex flex-shrink-0 flex-col'>
{!isCollapsed && (
<div className='sidebar-collapse-remove px-[16px] pb-[6px]'>
<div className='font-base text-[var(--text-icon)] text-small'>{title}</div>
</div>
)}
<div className='px-[16px] pb-[6px]'>
<div className='font-base text-[var(--text-icon)] text-small'>{title}</div>
</div>
<div className='flex flex-col gap-[2px] px-[8px]'>
{sectionItems.map((item) => {
const Icon = item.icon

View File

@@ -495,7 +495,7 @@ export function WorkspaceHeader({
</div>
</DropdownMenuGroup>
<div className='flex flex-col gap-[2px]'>
<div className='mt-[4px] flex flex-col gap-[2px]'>
<button
type='button'
className='flex w-full cursor-pointer select-none items-center gap-[8px] rounded-[5px] px-[8px] py-[5px] font-medium text-[12px] text-[var(--text-body)] outline-none transition-colors hover:bg-[var(--surface-active)] disabled:pointer-events-none disabled:opacity-50'

View File

@@ -1078,11 +1078,9 @@ 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>
)}
<div className='px-[16px] pb-[6px]'>
<div className='font-base text-[var(--text-icon)] text-small'>Workspace</div>
</div>
<div className='flex flex-col gap-[2px] px-[8px]'>
{workspaceNavItems.map((item) => (
<SidebarNavItem
@@ -1106,6 +1104,27 @@ export const Sidebar = memo(function Sidebar() {
>
{/* Tasks */}
<div className='flex flex-shrink-0 flex-col'>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-[16px]'>
<div className='font-base text-[var(--text-icon)] text-small'>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>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={
@@ -1114,6 +1133,7 @@ export const Sidebar = memo(function Sidebar() {
hover={tasksHover}
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
ariaLabel='Tasks'
className='mt-[6px]'
>
{tasksLoading ? (
<DropdownMenuItem disabled>
@@ -1140,211 +1160,183 @@ export const Sidebar = memo(function Sidebar() {
)}
</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>
)
}
<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 (
<SidebarTaskItem
<div
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}
/>
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>
)
})}
{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>
}
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'>
<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'>
Workflows
</div>
<div className='flex items-center justify-center gap-[8px]'>
<DropdownMenu>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
disabled={!canEdit}
>
{isImporting || isCreatingFolder ? (
<Loader className='h-[16px] w-[16px]' animate />
) : (
<MoreHorizontal className='h-[16px] w-[16px]' />
)}
</Button>
</DropdownMenuTrigger>
</Tooltip.Trigger>
<Tooltip.Content>
<p>More actions</p>
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenuContent
align='start'
sideOffset={8}
className='min-w-[160px]'
>
<DropdownMenuItem
onSelect={handleImportWorkflow}
disabled={!canEdit || isImporting}
>
<Download />
{isImporting ? 'Importing...' : 'Import workflow'}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleCreateFolder}
disabled={!canEdit || isCreatingFolder}
>
<FolderPlus />
{isCreatingFolder ? 'Creating folder...' : 'Create folder'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className='workflows-section relative mt-[14px] flex flex-col'>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-[16px]'>
<div className='font-base text-[var(--text-icon)] text-small'>Workflows</div>
{!isCollapsed && (
<div className='flex items-center justify-center gap-[8px]'>
<DropdownMenu>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow || !canEdit}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
disabled={!canEdit}
>
{isImporting || isCreatingFolder ? (
<Loader className='h-[16px] w-[16px]' animate />
) : (
<MoreHorizontal className='h-[16px] w-[16px]' />
)}
</Button>
</DropdownMenuTrigger>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{isCreatingWorkflow ? 'Creating workflow...' : 'New workflow'}</p>
<p>More actions</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<DropdownMenuContent
align='start'
sideOffset={8}
className='min-w-[160px]'
>
<DropdownMenuItem
onSelect={handleImportWorkflow}
disabled={!canEdit || isImporting}
>
<Download />
{isImporting ? 'Importing...' : 'Import workflow'}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleCreateFolder}
disabled={!canEdit || isCreatingFolder}
>
<FolderPlus />
{isCreatingFolder ? 'Creating folder...' : 'Create folder'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow || !canEdit}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{isCreatingWorkflow ? 'Creating workflow...' : 'New workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
)}
</div>
{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-[6px]'
>
{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='mt-[6px] px-[8px]'>
{workflowsLoading && regularWorkflows.length === 0 && <SidebarItemSkeleton />}
<WorkflowList
@@ -1361,8 +1353,8 @@ export const Sidebar = memo(function Sidebar() {
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
/>
</div>
</div>
)}
)}
</div>
</div>
{/* Footer */}

View File

@@ -0,0 +1,22 @@
/**
* PillsRing icon animation
* Pills arranged in a ring fade in/out sequentially,
* creating a chasing spinner effect.
* Individual pill delays are set via inline style.
*/
@keyframes pill-fade {
0%,
50%,
100% {
opacity: 0.15;
}
25% {
opacity: 1;
}
}
.animated-pills-ring-svg .pill {
animation: pill-fade 1.2s ease-in-out infinite;
will-change: opacity;
}

View File

@@ -0,0 +1,25 @@
import type { SVGProps } from 'react'
/**
* Columns2 icon component - displays two vertical columns in a rounded container
* @param props - SVG properties including className, fill, etc.
*/
export function Columns2(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M0.75 3.25C0.75 1.86929 1.86929 0.75 3.25 0.75H17.25C18.6307 0.75 19.75 1.86929 19.75 3.25V16.25C19.75 17.6307 18.6307 18.75 17.25 18.75H3.25C1.86929 18.75 0.75 17.6307 0.75 16.25V3.25Z' />
<path d='M10.25 0.75V18.75' />
</svg>
)
}

View File

@@ -15,6 +15,7 @@ export { Card } from './card'
export { Check } from './check'
export { ChevronDown } from './chevron-down'
export { ClipboardList } from './clipboard-list'
export { Columns2 } from './columns2'
export { Columns3 } from './columns3'
export { Connections } from './connections'
export { Copy } from './copy'
@@ -53,6 +54,7 @@ export { Palette } from './palette'
export { PanelLeft } from './panel-left'
export { Pause } from './pause'
export { Pencil } from './pencil'
export { PillsRing } from './pills-ring'
export { Play, PlayOutline } from './play'
export { Plus } from './plus'
export { Redo } from './redo'

View File

@@ -0,0 +1,52 @@
import type { SVGProps } from 'react'
import styles from '@/components/emcn/icons/animate/pills-ring.module.css'
export interface PillsRingProps extends SVGProps<SVGSVGElement> {
/**
* Enable the chasing fade animation
* @default false
*/
animate?: boolean
}
const PILL_COUNT = 8
const DURATION_S = 1.2
/**
* Ring of pill-shaped elements with optional chasing fade animation.
* Static render shows pills at graded opacities; animated render
* fades them sequentially around the ring via CSS module keyframes.
* @param props - SVG properties including className, animate, etc.
*/
export function PillsRing({ animate = false, className, ...props }: PillsRingProps) {
const svgClassName = animate
? `${styles['animated-pills-ring-svg']} ${className || ''}`.trim()
: className
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='currentColor'
className={svgClassName}
{...props}
>
{Array.from({ length: PILL_COUNT }).map((_, i) => (
<rect
key={i}
x='10.75'
y='2.5'
width='2.5'
height='5'
rx='1.25'
transform={`rotate(${i * 45} 12 12)`}
className={animate ? styles.pill : undefined}
style={animate ? { animationDelay: `${(i * DURATION_S) / PILL_COUNT}s` } : undefined}
opacity={animate ? undefined : 0.15 + (0.85 * (PILL_COUNT - i)) / PILL_COUNT}
/>
))}
</svg>
)
}

View File

@@ -1390,7 +1390,7 @@ export function AmplitudeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 49 49'>
<path
fill='#FFFFFF'
fill='currentColor'
d='M23.4,15.3c0.6,1.8,1.2,4.1,1.9,6.7c-2.6,0-5.3-0.1-7.8-0.1h-1.3c1.5-5.7,3.2-10.1,4.6-11.1 c0.1-0.1,0.2-0.1,0.4-0.1c0.2,0,0.3,0.1,0.5,0.3C21.9,11.5,22.5,12.7,23.4,15.3z M49,24.5C49,38,38,49,24.5,49S0,38,0,24.5 S11,0,24.5,0S49,11,49,24.5z M42.7,23.9c0-0.6-0.4-1.2-1-1.3l0,0l0,0l0,0c-0.1,0-0.1,0-0.2,0h-0.2c-4.1-0.3-8.4-0.4-12.4-0.5l0,0 C27,14.8,24.5,7.4,21.3,7.4c-3,0-5.8,4.9-8.2,14.5c-1.7,0-3.2,0-4.6-0.1c-0.1,0-0.2,0-0.2,0c-0.3,0-0.5,0-0.5,0 c-0.8,0.1-1.4,0.9-1.4,1.7c0,0.8,0.6,1.6,1.5,1.7l0,0h4.6c-0.4,1.9-0.8,3.8-1.1,5.6l-0.1,0.8l0,0c0,0.6,0.5,1.1,1.1,1.1 c0.4,0,0.8-0.2,1-0.5l0,0l2.2-7.1h10.7c0.8,3.1,1.7,6.3,2.8,9.3c0.6,1.6,2,5.4,4.4,5.4l0,0c3.6,0,5-5.8,5.9-9.6 c0.2-0.8,0.4-1.5,0.5-2.1l0.1-0.2l0,0c0-0.1,0-0.2,0-0.3c-0.1-0.2-0.2-0.3-0.4-0.4c-0.3-0.1-0.5,0.1-0.6,0.4l0,0l-0.1,0.2 c-0.3,0.8-0.6,1.6-0.8,2.3v0.1c-1.6,4.4-2.3,6.4-3.7,6.4l0,0l0,0l0,0c-1.8,0-3.5-7.3-4.1-10.1c-0.1-0.5-0.2-0.9-0.3-1.3h11.7 c0.2,0,0.4-0.1,0.6-0.1l0,0c0,0,0,0,0.1,0c0,0,0,0,0.1,0l0,0c0,0,0.1,0,0.1-0.1l0,0C42.5,24.6,42.7,24.3,42.7,23.9z'
/>
</svg>

View File

@@ -162,7 +162,7 @@ function buildQuery(sourceConfig: Record<string, unknown>): string {
const folderId = sourceConfig.folderId as string | undefined
if (folderId?.trim()) {
parts.push(`'${folderId.trim().replace(/'/g, "\\'")}' in parents`)
parts.push(`'${folderId.trim().replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' in parents`)
}
return parts.join(' and ')

View File

@@ -112,7 +112,7 @@ function buildQuery(sourceConfig: Record<string, unknown>): string {
const folderId = sourceConfig.folderId as string | undefined
if (folderId?.trim()) {
parts.push(`'${folderId.trim().replace(/'/g, "\\'")}' in parents`)
parts.push(`'${folderId.trim().replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' in parents`)
}
const fileType = (sourceConfig.fileType as string) || 'all'

View File

@@ -1,7 +0,0 @@
{
"id": "adam",
"name": "Adam Gough",
"url": "https://x.com/adamgough",
"xHandle": "adamgough",
"avatarUrl": "/studio/authors/adam.png"
}

View File

@@ -3,5 +3,5 @@
"name": "Emir Karabeg",
"url": "https://x.com/karabegemir",
"xHandle": "karabegemir",
"avatarUrl": "/studio/authors/emir.jpg"
"avatarUrl": "/blog/authors/emir.jpg"
}

View File

@@ -3,5 +3,5 @@
"name": "Siddharth",
"url": "https://x.com/sidganesan",
"xHandle": "sidganesan",
"avatarUrl": "/studio/authors/sid.jpg"
"avatarUrl": "/blog/authors/sid.jpg"
}

View File

@@ -3,5 +3,5 @@
"name": "Vikhyath Mondreti",
"url": "https://github.com/icecrasher321",
"xHandle": "icecrasher321",
"avatarUrl": "/studio/authors/vik.jpg"
"avatarUrl": "/blog/authors/vik.jpg"
}

View File

@@ -3,5 +3,5 @@
"name": "Waleed Latif",
"url": "https://x.com/typingwala",
"xHandle": "typingwala",
"avatarUrl": "/studio/authors/waleed.jpg"
"avatarUrl": "/blog/authors/waleed.jpg"
}

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