mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
8 Commits
fix/mother
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9d5e6000b | ||
|
|
d557bf527c | ||
|
|
dc98812bd7 | ||
|
|
c738226c06 | ||
|
|
8f15be23a0 | ||
|
|
b2d146ca0a | ||
|
|
d06aa1de7e | ||
|
|
5b9f0d73c2 |
@@ -117,6 +117,8 @@ export const {service}Connector: ConnectorConfig = {
|
||||
|
||||
The add-connector modal renders these automatically — no custom UI needed.
|
||||
|
||||
Three field types are supported: `short-input`, `dropdown`, and `selector`.
|
||||
|
||||
```typescript
|
||||
// Text input
|
||||
{
|
||||
@@ -141,6 +143,136 @@ The add-connector modal renders these automatically — no custom UI needed.
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic Selectors (Canonical Pairs)
|
||||
|
||||
Use `type: 'selector'` to fetch options dynamically from the existing selector registry (`hooks/selectors/registry.ts`). Selectors are always paired with a manual fallback input using the **canonical pair** pattern — a `selector` field (basic mode) and a `short-input` field (advanced mode) linked by `canonicalParamId`.
|
||||
|
||||
The user sees a toggle button (ArrowLeftRight) to switch between the selector dropdown and manual text input. On submit, the modal resolves each canonical pair to the active mode's value, keyed by `canonicalParamId`.
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Every selector field MUST have a canonical pair** — a corresponding `short-input` (or `dropdown`) field with the same `canonicalParamId` and `mode: 'advanced'`.
|
||||
2. **`required` must be set identically on both fields** in a pair. If the selector is required, the manual input must also be required.
|
||||
3. **`canonicalParamId` must match the key the connector expects in `sourceConfig`** (e.g. `baseId`, `channel`, `teamId`). The advanced field's `id` should typically match `canonicalParamId`.
|
||||
4. **`dependsOn` references the selector field's `id`**, not the `canonicalParamId`. The modal propagates dependency clearing across canonical siblings automatically — changing either field in a parent pair clears dependent children.
|
||||
|
||||
### Selector canonical pair example (Airtable base → table cascade)
|
||||
|
||||
```typescript
|
||||
configFields: [
|
||||
// Base: selector (basic) + manual (advanced)
|
||||
{
|
||||
id: 'baseSelector',
|
||||
title: 'Base',
|
||||
type: 'selector',
|
||||
selectorKey: 'airtable.bases', // Must exist in hooks/selectors/registry.ts
|
||||
canonicalParamId: 'baseId',
|
||||
mode: 'basic',
|
||||
placeholder: 'Select a base',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'baseId',
|
||||
title: 'Base ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'baseId',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. appXXXXXXXXXXXXXX',
|
||||
required: true,
|
||||
},
|
||||
// Table: selector depends on base (basic) + manual (advanced)
|
||||
{
|
||||
id: 'tableSelector',
|
||||
title: 'Table',
|
||||
type: 'selector',
|
||||
selectorKey: 'airtable.tables',
|
||||
canonicalParamId: 'tableIdOrName',
|
||||
mode: 'basic',
|
||||
dependsOn: ['baseSelector'], // References the selector field ID
|
||||
placeholder: 'Select a table',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'tableIdOrName',
|
||||
title: 'Table Name or ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'tableIdOrName',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. Tasks',
|
||||
required: true,
|
||||
},
|
||||
// Non-selector fields stay as-is
|
||||
{ id: 'maxRecords', title: 'Max Records', type: 'short-input', ... },
|
||||
]
|
||||
```
|
||||
|
||||
### Selector with domain dependency (Jira/Confluence pattern)
|
||||
|
||||
When a selector depends on a plain `short-input` field (no canonical pair), `dependsOn` references that field's `id` directly. The `domain` field's value maps to `SelectorContext.domain` automatically via `SELECTOR_CONTEXT_FIELDS`.
|
||||
|
||||
```typescript
|
||||
configFields: [
|
||||
{
|
||||
id: 'domain',
|
||||
title: 'Jira Domain',
|
||||
type: 'short-input',
|
||||
placeholder: 'yoursite.atlassian.net',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'projectSelector',
|
||||
title: 'Project',
|
||||
type: 'selector',
|
||||
selectorKey: 'jira.projects',
|
||||
canonicalParamId: 'projectKey',
|
||||
mode: 'basic',
|
||||
dependsOn: ['domain'],
|
||||
placeholder: 'Select a project',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'projectKey',
|
||||
title: 'Project Key',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'projectKey',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. ENG, PROJ',
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
### How `dependsOn` maps to `SelectorContext`
|
||||
|
||||
The connector selector field builds a `SelectorContext` from dependency values. For the mapping to work, each dependency's `canonicalParamId` (or field `id` for non-canonical fields) must exist in `SELECTOR_CONTEXT_FIELDS` (`lib/workflows/subblocks/context.ts`):
|
||||
|
||||
```
|
||||
oauthCredential, domain, teamId, projectId, knowledgeBaseId, planId,
|
||||
siteId, collectionId, spreadsheetId, fileId, baseId, datasetId, serviceDeskId
|
||||
```
|
||||
|
||||
### Available selector keys
|
||||
|
||||
Check `hooks/selectors/types.ts` for the full `SelectorKey` union. Common ones for connectors:
|
||||
|
||||
| SelectorKey | Context Deps | Returns |
|
||||
|-------------|-------------|---------|
|
||||
| `airtable.bases` | credential | Base ID + name |
|
||||
| `airtable.tables` | credential, `baseId` | Table ID + name |
|
||||
| `slack.channels` | credential | Channel ID + name |
|
||||
| `gmail.labels` | credential | Label ID + name |
|
||||
| `google.calendar` | credential | Calendar ID + name |
|
||||
| `linear.teams` | credential | Team ID + name |
|
||||
| `linear.projects` | credential, `teamId` | Project ID + name |
|
||||
| `jira.projects` | credential, `domain` | Project key + name |
|
||||
| `confluence.spaces` | credential, `domain` | Space key + name |
|
||||
| `notion.databases` | credential | Database ID + name |
|
||||
| `asana.workspaces` | credential | Workspace GID + name |
|
||||
| `microsoft.teams` | credential | Team ID + name |
|
||||
| `microsoft.channels` | credential, `teamId` | Channel ID + name |
|
||||
| `webflow.sites` | credential | Site ID + name |
|
||||
| `outlook.folders` | credential | Folder ID + name |
|
||||
|
||||
## ExternalDocument Shape
|
||||
|
||||
Every document returned from `listDocuments`/`getDocument` must include:
|
||||
@@ -287,6 +419,12 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = {
|
||||
- [ ] **Auth configured correctly:**
|
||||
- OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts`
|
||||
- API key: `auth.label` and `auth.placeholder` set appropriately
|
||||
- [ ] **Selector fields configured correctly (if applicable):**
|
||||
- Every `type: 'selector'` field has a canonical pair (`short-input` or `dropdown` with same `canonicalParamId` and `mode: 'advanced'`)
|
||||
- `required` is identical on both fields in each canonical pair
|
||||
- `selectorKey` exists in `hooks/selectors/registry.ts`
|
||||
- `dependsOn` references selector field IDs (not `canonicalParamId`)
|
||||
- Dependency `canonicalParamId` values exist in `SELECTOR_CONTEXT_FIELDS`
|
||||
- [ ] `listDocuments` handles pagination and computes content hashes
|
||||
- [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative)
|
||||
- [ ] `metadata` includes source-specific data for tag mapping
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
'use client'
|
||||
import { memo } from 'react'
|
||||
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
|
||||
/** Shared corner radius from Figma export for all decorative rects. */
|
||||
const RX = '2.59574'
|
||||
|
||||
const ENTER_STAGGER = 0.06
|
||||
const ENTER_DURATION = 0.3
|
||||
const EXIT_STAGGER = 0.12
|
||||
const EXIT_DURATION = 0.5
|
||||
const INITIAL_HOLD = 3000
|
||||
const HOLD_BETWEEN = 3000
|
||||
const TRANSITION_PAUSE = 400
|
||||
|
||||
interface BlockRect {
|
||||
opacity: number
|
||||
width: string
|
||||
@@ -23,8 +12,6 @@ interface BlockRect {
|
||||
transform?: string
|
||||
}
|
||||
|
||||
type AnimState = 'visible' | 'exiting' | 'hidden'
|
||||
|
||||
const RECTS = {
|
||||
topRight: [
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
|
||||
@@ -67,76 +54,33 @@ const RECTS = {
|
||||
fill: '#FA4EDF',
|
||||
},
|
||||
],
|
||||
left: [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 0)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '68.480',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.727 0)',
|
||||
},
|
||||
bottomLeft: [
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
|
||||
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||
{
|
||||
opacity: 1,
|
||||
x: '51.6188',
|
||||
y: '16.8626',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(-1 0 0 1 33.727 17.378)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.986',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0 51.616)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '140.507',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(-1 0 0 1 33.986 85.335)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '34.240',
|
||||
height: '16.8626',
|
||||
fill: '#FFCC02',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.119',
|
||||
y: '136.962',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#FFCC02',
|
||||
transform: 'rotate(-90 17.119 136.962)',
|
||||
},
|
||||
{
|
||||
opacity: 0.5,
|
||||
width: '34.240',
|
||||
height: '33.725',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
fill: '#2ABBF8',
|
||||
},
|
||||
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
|
||||
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#00F701' },
|
||||
{
|
||||
opacity: 1,
|
||||
x: '123.6484',
|
||||
y: '16.8626',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||
},
|
||||
],
|
||||
right: [
|
||||
bottomRight: [
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
@@ -175,68 +119,33 @@ const RECTS = {
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '33.726',
|
||||
fill: '#FA4EDF',
|
||||
transform: 'matrix(0 1 1 0 0.012 68.510)',
|
||||
},
|
||||
{
|
||||
opacity: 0.6,
|
||||
width: '16.8626',
|
||||
height: '102.384',
|
||||
height: '34.24',
|
||||
fill: '#2ABBF8',
|
||||
transform: 'matrix(-1 0 0 1 33.787 102.384)',
|
||||
transform: 'matrix(-1 0 0 1 33.787 68)',
|
||||
},
|
||||
{
|
||||
opacity: 0.4,
|
||||
x: '17.131',
|
||||
y: '153.859',
|
||||
width: '34.241',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.131 153.859)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
x: '17.131',
|
||||
y: '153.859',
|
||||
width: '16.8626',
|
||||
height: '16.8626',
|
||||
fill: '#00F701',
|
||||
transform: 'rotate(-90 17.131 153.859)',
|
||||
fill: '#1A8FCC',
|
||||
transform: 'matrix(-1 0 0 1 33.787 85)',
|
||||
},
|
||||
],
|
||||
} as const satisfies Record<string, readonly BlockRect[]>
|
||||
|
||||
type Position = keyof typeof RECTS
|
||||
|
||||
function enterTime(pos: Position): number {
|
||||
return (RECTS[pos].length - 1) * ENTER_STAGGER + ENTER_DURATION
|
||||
}
|
||||
|
||||
function exitTime(pos: Position): number {
|
||||
return (RECTS[pos].length - 1) * EXIT_STAGGER + EXIT_DURATION
|
||||
}
|
||||
|
||||
interface BlockGroupProps {
|
||||
width: number
|
||||
height: number
|
||||
viewBox: string
|
||||
rects: readonly BlockRect[]
|
||||
animState: AnimState
|
||||
globalOpacity: number
|
||||
}
|
||||
const GLOBAL_OPACITY = 0.55
|
||||
|
||||
const BlockGroup = memo(function BlockGroup({
|
||||
width,
|
||||
height,
|
||||
viewBox,
|
||||
rects,
|
||||
animState,
|
||||
globalOpacity,
|
||||
}: BlockGroupProps) {
|
||||
const isVisible = animState === 'visible'
|
||||
const isExiting = animState === 'exiting'
|
||||
|
||||
}: {
|
||||
width: number
|
||||
height: number
|
||||
viewBox: string
|
||||
rects: readonly BlockRect[]
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
@@ -245,7 +154,7 @@ const BlockGroup = memo(function BlockGroup({
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className='h-auto w-full'
|
||||
style={{ opacity: globalOpacity }}
|
||||
style={{ opacity: GLOBAL_OPACITY }}
|
||||
>
|
||||
{rects.map((r, i) => (
|
||||
<rect
|
||||
@@ -257,114 +166,29 @@ const BlockGroup = memo(function BlockGroup({
|
||||
rx={RX}
|
||||
fill={r.fill}
|
||||
transform={r.transform}
|
||||
style={{
|
||||
opacity: isVisible ? r.opacity : 0,
|
||||
transition: `opacity ${isExiting ? EXIT_DURATION : ENTER_DURATION}s ease ${
|
||||
isVisible ? i * ENTER_STAGGER : isExiting ? i * EXIT_STAGGER : 0
|
||||
}s`,
|
||||
}}
|
||||
opacity={r.opacity}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
})
|
||||
|
||||
function useGroupState(): [AnimState, (s: AnimState) => void] {
|
||||
return useState<AnimState>('visible')
|
||||
}
|
||||
|
||||
function useBlockCycle() {
|
||||
const [topRight, setTopRight] = useGroupState()
|
||||
const [left, setLeft] = useGroupState()
|
||||
const [right, setRight] = useGroupState()
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && !window.matchMedia('(min-width: 1024px)').matches) return
|
||||
|
||||
const cancelled = { current: false }
|
||||
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
||||
|
||||
async function exit(setter: (s: AnimState) => void, pos: Position, pauseAfter: number) {
|
||||
if (cancelled.current) return
|
||||
setter('exiting')
|
||||
await wait(exitTime(pos) * 1000)
|
||||
if (cancelled.current) return
|
||||
setter('hidden')
|
||||
await wait(pauseAfter)
|
||||
}
|
||||
|
||||
async function enter(setter: (s: AnimState) => void, pos: Position, pauseAfter: number) {
|
||||
if (cancelled.current) return
|
||||
setter('visible')
|
||||
await wait(enterTime(pos) * 1000 + pauseAfter)
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
await wait(INITIAL_HOLD)
|
||||
|
||||
while (!cancelled.current) {
|
||||
await exit(setTopRight, 'topRight', TRANSITION_PAUSE)
|
||||
await exit(setLeft, 'left', HOLD_BETWEEN)
|
||||
await enter(setLeft, 'left', TRANSITION_PAUSE)
|
||||
await enter(setTopRight, 'topRight', TRANSITION_PAUSE)
|
||||
await exit(setRight, 'right', HOLD_BETWEEN)
|
||||
await enter(setRight, 'right', HOLD_BETWEEN)
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
return () => {
|
||||
cancelled.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { topRight, left, right } as const
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambient animated block decorations for the docs layout.
|
||||
* Adapts the landing page's colorful block patterns with slightly reduced
|
||||
* opacity and the same staggered enter/exit animation cycle.
|
||||
*/
|
||||
export function AnimatedBlocks() {
|
||||
const states = useBlockCycle()
|
||||
|
||||
return (
|
||||
<div
|
||||
className='pointer-events-none fixed inset-0 z-0 hidden overflow-hidden lg:block'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<div className='absolute top-[93px] right-0 w-[calc(140px+10.76vw)] max-w-[295px]'>
|
||||
<BlockGroup
|
||||
width={295}
|
||||
height={34}
|
||||
viewBox='0 0 295 34'
|
||||
rects={RECTS.topRight}
|
||||
animState={states.topRight}
|
||||
globalOpacity={0.75}
|
||||
/>
|
||||
<BlockGroup width={295} height={34} viewBox='0 0 295 34' rects={RECTS.topRight} />
|
||||
</div>
|
||||
|
||||
<div className='-translate-y-1/2 absolute top-[50%] left-0 w-[calc(16px+1.25vw)] max-w-[34px] scale-x-[-1]'>
|
||||
<BlockGroup
|
||||
width={34}
|
||||
height={226}
|
||||
viewBox='0 0 34 226.021'
|
||||
rects={RECTS.left}
|
||||
animState={states.left}
|
||||
globalOpacity={0.75}
|
||||
/>
|
||||
<div className='-left-24 absolute bottom-0 w-[calc(140px+10.76vw)] max-w-[295px] rotate-180'>
|
||||
<BlockGroup width={295} height={34} viewBox='0 0 295 34' rects={RECTS.bottomLeft} />
|
||||
</div>
|
||||
|
||||
<div className='-translate-y-1/2 absolute top-[50%] right-0 w-[calc(16px+1.25vw)] max-w-[34px]'>
|
||||
<BlockGroup
|
||||
width={34}
|
||||
height={205}
|
||||
viewBox='0 0 34 204.769'
|
||||
rects={RECTS.right}
|
||||
animState={states.right}
|
||||
globalOpacity={0.75}
|
||||
/>
|
||||
<div className='-bottom-2 absolute right-0 w-[calc(16px+1.25vw)] max-w-[34px]'>
|
||||
<BlockGroup width={34} height={102} viewBox='0 0 34 102' rects={RECTS.bottomRight} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#2A2A2A]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -268,13 +268,13 @@ export default function Collaboration() {
|
||||
collaboration
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
|
||||
<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>
|
||||
|
||||
<Link
|
||||
href='/signup'
|
||||
className='group/cta mt-[12px] inline-flex h-[32px] cursor-none items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
|
||||
className='group/cta mt-[12px] inline-flex h-[32px] cursor-none items-center gap-[6px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
Build together
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { type SVGProps, useRef } from 'react'
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
import { ChevronDown } from '@/components/emcn'
|
||||
import { Database, File, Library, Table } from '@/components/emcn/icons'
|
||||
import {
|
||||
AnthropicIcon,
|
||||
GeminiIcon,
|
||||
GmailIcon,
|
||||
GroqIcon,
|
||||
HubspotIcon,
|
||||
OpenAIIcon,
|
||||
SalesforceIcon,
|
||||
SlackIcon,
|
||||
xAIIcon,
|
||||
} from '@/components/icons'
|
||||
|
||||
interface IconEntry {
|
||||
key: string
|
||||
icon: React.ComponentType<SVGProps<SVGSVGElement>>
|
||||
label: string
|
||||
top: string
|
||||
left: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
const SCATTERED_ICONS: IconEntry[] = [
|
||||
{ key: 'slack', icon: SlackIcon, label: 'Slack', top: '8%', left: '14%' },
|
||||
{ key: 'openai', icon: OpenAIIcon, label: 'OpenAI', top: '8%', left: '44%' },
|
||||
{ key: 'anthropic', icon: AnthropicIcon, label: 'Anthropic', top: '10%', left: '78%' },
|
||||
{ key: 'gmail', icon: GmailIcon, label: 'Gmail', top: '24%', left: '90%' },
|
||||
{ key: 'salesforce', icon: SalesforceIcon, label: 'Salesforce', top: '28%', left: '6%' },
|
||||
{ key: 'table', icon: Table, label: 'Tables', top: '22%', left: '30%' },
|
||||
{ key: 'xai', icon: xAIIcon, label: 'xAI', top: '26%', left: '66%' },
|
||||
{ key: 'hubspot', icon: HubspotIcon, label: 'HubSpot', top: '55%', left: '4%', color: '#FF7A59' },
|
||||
{ key: 'database', icon: Database, label: 'Database', top: '74%', left: '68%' },
|
||||
{ key: 'file', icon: File, label: 'Files', top: '70%', left: '18%' },
|
||||
{ key: 'gemini', icon: GeminiIcon, label: 'Gemini', top: '58%', left: '86%' },
|
||||
{ key: 'logs', icon: Library, label: 'Logs', top: '86%', left: '44%' },
|
||||
{ key: 'groq', icon: GroqIcon, label: 'Groq', top: '90%', left: '82%' },
|
||||
]
|
||||
|
||||
const EXPLODE_STAGGER = 0.04
|
||||
const EXPLODE_BASE_DELAY = 0.1
|
||||
|
||||
export function FeaturesPreview() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inView = useInView(containerRef, { once: true, margin: '-80px' })
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className='relative h-[560px] w-full overflow-hidden'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute inset-0'
|
||||
style={{
|
||||
backgroundImage: 'radial-gradient(circle, #D4D4D4 0.75px, transparent 0.75px)',
|
||||
backgroundSize: '12px 12px',
|
||||
maskImage: 'radial-gradient(ellipse 70% 65% at 48% 50%, black 30%, transparent 80%)',
|
||||
WebkitMaskImage:
|
||||
'radial-gradient(ellipse 70% 65% at 48% 50%, black 30%, transparent 80%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{SCATTERED_ICONS.map(({ key, icon: Icon, label, top, left, color }, index) => {
|
||||
const explodeDelay = EXPLODE_BASE_DELAY + index * EXPLODE_STAGGER
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={key}
|
||||
className='absolute flex items-center justify-center rounded-xl border border-[#E5E5E5] bg-white p-[10px] shadow-[0_2px_4px_0_rgba(0,0,0,0.06)]'
|
||||
initial={{ top: '50%', left: '50%', opacity: 0, scale: 0, x: '-50%', y: '-50%' }}
|
||||
animate={inView ? { top, left, opacity: 1, scale: 1, x: '-50%', y: '-50%' } : undefined}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 50,
|
||||
damping: 12,
|
||||
delay: explodeDelay,
|
||||
}}
|
||||
style={{ color }}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className='h-6 w-6' />
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
|
||||
<motion.div
|
||||
className='absolute top-1/2 left-[48%]'
|
||||
initial={{ opacity: 0, x: '-50%', y: '-50%' }}
|
||||
animate={inView ? { opacity: 1, x: '-50%', y: '-50%' } : undefined}
|
||||
transition={{ duration: 0.4, ease: 'easeOut', delay: 0 }}
|
||||
>
|
||||
<div className='flex h-[36px] items-center gap-[8px] rounded-[8px] border border-[#E5E5E5] bg-white px-[10px] shadow-[0_2px_6px_0_rgba(0,0,0,0.08)]'>
|
||||
<div className='flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-[5px] bg-[#1e1e1e]'>
|
||||
<svg width='11' height='11' viewBox='0 0 10 10' fill='none'>
|
||||
<path
|
||||
d='M1 9C1 4.58 4.58 1 9 1'
|
||||
stroke='white'
|
||||
strokeWidth='1.8'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className='whitespace-nowrap font-medium font-season text-[#1C1C1C] text-[13px] tracking-[0.02em]'>
|
||||
My Workspace
|
||||
</span>
|
||||
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0 text-[#999]' />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
|
||||
import Image from 'next/image'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import Link from 'next/link'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
import { FeaturesPreview } from '@/app/(home)/components/features/components/features-preview'
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = Number.parseInt(hex.slice(1, 3), 16)
|
||||
@@ -13,8 +16,12 @@ function hexToRgba(hex: string, alpha: number): string {
|
||||
|
||||
const FEATURE_TABS = [
|
||||
{
|
||||
label: 'Integrations',
|
||||
label: 'Mothership',
|
||||
color: '#FA4EDF',
|
||||
title: 'Your AI command center',
|
||||
description:
|
||||
'Direct your entire AI workforce from one place. Build agents, spin up workflows, query tables, and manage every resource across your workspace — in natural language.',
|
||||
cta: 'Explore mothership',
|
||||
segments: [
|
||||
[0.3, 8],
|
||||
[0.25, 10],
|
||||
@@ -29,8 +36,12 @@ const FEATURE_TABS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Copilot',
|
||||
label: 'Tables',
|
||||
color: '#2ABBF8',
|
||||
title: 'A database, built in',
|
||||
description:
|
||||
'Filter, sort, and edit data inline, then wire it directly into your workflows. Agents query, insert, and update rows on every run — no external database needed.',
|
||||
cta: 'Explore tables',
|
||||
segments: [
|
||||
[0.25, 12],
|
||||
[0.4, 10],
|
||||
@@ -44,59 +55,33 @@ const FEATURE_TABS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Models',
|
||||
color: '#00F701',
|
||||
badgeColor: '#22C55E',
|
||||
segments: [
|
||||
[0.2, 6],
|
||||
[0.35, 10],
|
||||
[0.3, 8],
|
||||
[0.5, 10],
|
||||
[0.6, 8],
|
||||
[0.75, 12],
|
||||
[0.85, 10],
|
||||
[1, 8],
|
||||
[0.9, 12],
|
||||
[1, 10],
|
||||
[0.95, 6],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Deploy',
|
||||
label: 'Files',
|
||||
color: '#FFCC02',
|
||||
badgeColor: '#EAB308',
|
||||
segments: [
|
||||
[0.3, 12],
|
||||
[0.25, 8],
|
||||
[0.4, 10],
|
||||
[0.55, 10],
|
||||
[0.7, 8],
|
||||
[0.6, 10],
|
||||
[0.85, 12],
|
||||
[1, 10],
|
||||
[0.9, 10],
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
color: '#FF6B35',
|
||||
title: 'Upload, create, and share',
|
||||
description:
|
||||
'Create or upload documents, spreadsheets, and media that agents can read, write, and reference across workflows. One shared store your entire team and every agent can pull from.',
|
||||
cta: 'Explore files',
|
||||
segments: [
|
||||
[0.25, 10],
|
||||
[0.35, 8],
|
||||
[0.3, 10],
|
||||
[0.4, 8],
|
||||
[0.35, 12],
|
||||
[0.5, 10],
|
||||
[0.65, 8],
|
||||
[0.8, 12],
|
||||
[0.9, 10],
|
||||
[0.75, 10],
|
||||
[0.9, 12],
|
||||
[1, 10],
|
||||
[0.85, 12],
|
||||
[0.85, 10],
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Knowledge Base',
|
||||
color: '#8B5CF6',
|
||||
title: 'Your context engine',
|
||||
description:
|
||||
'Sync institutional knowledge from 30+ live connectors — Notion, Drive, Slack, Confluence, and more — so every agent draws from the same truth across your entire organization.',
|
||||
cta: 'Explore knowledge base',
|
||||
segments: [
|
||||
[0.3, 10],
|
||||
[0.25, 8],
|
||||
@@ -110,8 +95,47 @@ const FEATURE_TABS = [
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
color: '#FF6B35',
|
||||
title: 'Full visibility, every run',
|
||||
description:
|
||||
'Trace every execution block by block — inputs, outputs, cost, and duration. Filter by status or workflow, replay snapshots, and export reports to keep your team accountable.',
|
||||
cta: 'Explore logs',
|
||||
segments: [
|
||||
[0.25, 10],
|
||||
[0.35, 8],
|
||||
[0.3, 10],
|
||||
[0.5, 10],
|
||||
[0.65, 8],
|
||||
[0.8, 12],
|
||||
[0.9, 10],
|
||||
[1, 10],
|
||||
[0.85, 12],
|
||||
[1, 10],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const HEADING_TEXT = 'Everything you need to build, deploy, and manage AI agents. '
|
||||
const HEADING_LETTERS = HEADING_TEXT.split('')
|
||||
|
||||
const LETTER_REVEAL_SPAN = 0.85
|
||||
const LETTER_FADE_IN = 0.04
|
||||
|
||||
interface ScrollLetterProps {
|
||||
scrollYProgress: MotionValue<number>
|
||||
charIndex: number
|
||||
children: string
|
||||
}
|
||||
|
||||
function ScrollLetter({ scrollYProgress, charIndex, children }: ScrollLetterProps) {
|
||||
const threshold = (charIndex / HEADING_LETTERS.length) * LETTER_REVEAL_SPAN
|
||||
const opacity = useTransform(scrollYProgress, [threshold, threshold + LETTER_FADE_IN], [0.4, 1])
|
||||
|
||||
return <motion.span style={{ opacity }}>{children}</motion.span>
|
||||
}
|
||||
|
||||
function DotGrid({
|
||||
cols,
|
||||
rows,
|
||||
@@ -126,7 +150,7 @@ function DotGrid({
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={`shrink-0 bg-[#FDFDFD] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
|
||||
className={`shrink-0 bg-[#F6F6F6] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
|
||||
style={{
|
||||
width: width ? `${width}px` : undefined,
|
||||
display: 'grid',
|
||||
@@ -136,20 +160,26 @@ function DotGrid({
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#DEDEDE]' />
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#DEDEDE]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Features() {
|
||||
const sectionRef = useRef<HTMLDivElement>(null)
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionRef,
|
||||
offset: ['start 0.9', 'start 0.2'],
|
||||
})
|
||||
|
||||
return (
|
||||
<section
|
||||
id='features'
|
||||
aria-labelledby='features-heading'
|
||||
className='relative overflow-hidden bg-[#F6F6F6] pb-[144px]'
|
||||
className='relative overflow-hidden bg-[#F6F6F6]'
|
||||
>
|
||||
<div aria-hidden='true' className='absolute top-0 left-0 w-full'>
|
||||
<Image
|
||||
@@ -163,7 +193,7 @@ export default function Features() {
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 pt-[100px]'>
|
||||
<div className='flex flex-col items-start gap-[20px] px-[80px]'>
|
||||
<div ref={sectionRef} className='flex flex-col items-start gap-[20px] px-[80px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
size='md'
|
||||
@@ -177,51 +207,110 @@ export default function Features() {
|
||||
),
|
||||
}}
|
||||
>
|
||||
Features
|
||||
Workspace
|
||||
</Badge>
|
||||
<h2
|
||||
id='features-heading'
|
||||
className='font-[430] font-season text-[#1C1C1C] text-[40px] leading-[100%] tracking-[-0.02em]'
|
||||
className='max-w-[900px] font-[430] font-season text-[#1C1C1C] text-[40px] leading-[110%] tracking-[-0.02em]'
|
||||
>
|
||||
Power your AI workforce
|
||||
{HEADING_LETTERS.map((char, i) => (
|
||||
<ScrollLetter key={i} scrollYProgress={scrollYProgress} charIndex={i}>
|
||||
{char}
|
||||
</ScrollLetter>
|
||||
))}
|
||||
<span className='text-[#1C1C1C]/40'>
|
||||
Design powerful workflows, connect your data, and monitor every run — all in one
|
||||
platform.
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='mt-[73px] flex h-[68px] overflow-hidden border border-[#E9E9E9]'>
|
||||
<DotGrid cols={10} rows={8} width={80} />
|
||||
<div className='relative mt-[73px] pb-[80px]'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 bottom-0 left-[80px] z-20 w-px bg-[#E9E9E9]'
|
||||
/>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-0 right-[80px] bottom-0 z-20 w-px bg-[#E9E9E9]'
|
||||
/>
|
||||
|
||||
<div role='tablist' aria-label='Feature categories' className='flex flex-1'>
|
||||
{FEATURE_TABS.map((tab, index) => (
|
||||
<button
|
||||
key={tab.label}
|
||||
type='button'
|
||||
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'
|
||||
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
|
||||
>
|
||||
{tab.label}
|
||||
{index === activeTab && (
|
||||
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
|
||||
{tab.segments.map(([opacity, width], i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='h-full shrink-0'
|
||||
style={{
|
||||
width: `${width}%`,
|
||||
backgroundColor: tab.color,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div className='flex h-[68px] overflow-hidden border border-[#E9E9E9]'>
|
||||
<DotGrid cols={10} rows={8} width={80} />
|
||||
|
||||
<div role='tablist' aria-label='Feature categories' className='flex flex-1'>
|
||||
{FEATURE_TABS.map((tab, index) => (
|
||||
<button
|
||||
key={tab.label}
|
||||
type='button'
|
||||
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'
|
||||
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
|
||||
>
|
||||
{tab.label}
|
||||
{index === activeTab && (
|
||||
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
|
||||
{tab.segments.map(([opacity, width], i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='h-full shrink-0'
|
||||
style={{
|
||||
width: `${width}%`,
|
||||
backgroundColor: tab.color,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DotGrid cols={10} rows={8} width={80} borderLeft />
|
||||
</div>
|
||||
|
||||
<DotGrid cols={10} rows={8} width={80} borderLeft />
|
||||
<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='flex flex-col items-start gap-[16px]'>
|
||||
<h3 className='font-[430] font-season text-[#1C1C1C] text-[28px] leading-[120%] tracking-[-0.02em]'>
|
||||
{FEATURE_TABS[activeTab].title}
|
||||
</h3>
|
||||
<p className='font-[430] font-season text-[#1C1C1C]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
|
||||
{FEATURE_TABS[activeTab].description}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='group/cta inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-[10px] font-[430] font-season text-[14px] text-white transition-colors hover:border-[#2A2A2A] hover:bg-[#2A2A2A]'
|
||||
>
|
||||
{FEATURE_TABS[activeTab].cta}
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
|
||||
<svg
|
||||
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
|
||||
viewBox='0 0 10 10'
|
||||
fill='none'
|
||||
>
|
||||
<path
|
||||
d='M1 5H8M5.5 2L8.5 5L5.5 8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.33'
|
||||
strokeLinecap='square'
|
||||
strokeLinejoin='miter'
|
||||
fill='none'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<FeaturesPreview />
|
||||
</div>
|
||||
|
||||
<div aria-hidden='true' className='mt-[60px] h-px bg-[#E9E9E9]' />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,18 +1,189 @@
|
||||
/**
|
||||
* Landing page footer — navigation, legal links, and entity reinforcement.
|
||||
*
|
||||
* SEO:
|
||||
* - `<footer role="contentinfo">` with `<nav aria-label="Footer navigation">`.
|
||||
* - Link groups under semantic headings (`<h3>`). All links are `<Link>` or `<a>` with `href`.
|
||||
* - External links include `rel="noopener noreferrer"`.
|
||||
* - Legal links (Privacy, Terms) must be crawlable (trust signals).
|
||||
*
|
||||
* GEO:
|
||||
* - Include "Sim — Build AI agents and run your agentic workforce" as visible text (entity reinforcement).
|
||||
* - Social links (X, GitHub, LinkedIn, Discord) must match `sameAs` in structured-data.tsx.
|
||||
* - Link to all major pages: Docs, Pricing, Enterprise, Careers, Changelog (internal link graph).
|
||||
* - Display compliance badges (SOC2, HIPAA) and status page link as visible trust signals.
|
||||
*/
|
||||
export default function Footer() {
|
||||
return null
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { FOOTER_BLOCKS, FOOTER_TOOLS } from '@/app/(landing)/components/footer/consts'
|
||||
|
||||
const LINK_CLASS = 'text-[14px] text-[#999] transition-colors hover:text-[#ECECEC]'
|
||||
|
||||
interface FooterLink {
|
||||
label: string
|
||||
href: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
const FOOTER_LINKS: FooterLink[] = [
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
{ 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: '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() {
|
||||
return (
|
||||
<footer
|
||||
role='contentinfo'
|
||||
className='relative w-full overflow-hidden bg-[#1C1C1C] font-[430] font-season text-[14px]'
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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-[71px]'
|
||||
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[100px] pb-[12px]'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect
|
||||
@@ -53,7 +53,7 @@ export default function Hero() {
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute top-[-2.8vw] right-[0vw] z-0 aspect-[471/470] w-[32.7vw]'
|
||||
className='pointer-events-none absolute top-[-2.8vw] right-[-4vw] z-0 aspect-[471/470] w-[32.7vw]'
|
||||
>
|
||||
<Image src='/landing/card-right.svg' alt='' fill className='object-contain' />
|
||||
</div>
|
||||
@@ -61,25 +61,25 @@ 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-[64px] text-white leading-[100%] tracking-[-0.02em]'
|
||||
className='font-[430] font-season text-[72px] text-white leading-[100%] tracking-[-0.02em]'
|
||||
>
|
||||
Build Agents
|
||||
Build AI Agents
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[16px] leading-[125%] tracking-[0.02em]'>
|
||||
Build and deploy agentic workflows
|
||||
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
|
||||
Sim is the AI Workspace for Agent Builders.
|
||||
</p>
|
||||
|
||||
<div className='mt-[12px] flex items-center gap-[8px]'>
|
||||
<Link
|
||||
href='/login'
|
||||
href='/enterprise'
|
||||
className={`${CTA_BASE} border-[#3d3d3d] text-[#ECECEC] transition-colors hover:bg-[#2A2A2A]`}
|
||||
aria-label='Log in'
|
||||
aria-label='Get a demo'
|
||||
>
|
||||
Log in
|
||||
Get a demo
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className={`${CTA_BASE} gap-[8px] border-[#33C482] bg-[#33C482] text-black transition-[filter] hover:brightness-110`}
|
||||
className={`${CTA_BASE} gap-[8px] border-[#FFFFFF] bg-[#FFFFFF] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
@@ -101,7 +101,7 @@ export default function Hero() {
|
||||
<BlocksTopLeftAnimated animState={blockStates.topLeft} />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 mx-auto mt-[2.4vw] w-[78.9vw] px-[1.4vw]'>
|
||||
<div className='relative z-10 mx-auto mt-[3.2vw] w-[78.9vw] px-[1.4vw]'>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
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 C = {
|
||||
SURFACE: '#292929',
|
||||
BORDER: '#3d3d3d',
|
||||
TEXT_PRIMARY: '#e6e6e6',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Landing preview replica of the workspace Home initial view.
|
||||
* Shows a greeting heading and a minimal chat input (no + or mic).
|
||||
* On submit, stores the prompt and redirects to /signup.
|
||||
*/
|
||||
export const LandingPreviewHome = memo(function LandingPreviewHome() {
|
||||
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 MAX_HEIGHT = 200
|
||||
|
||||
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 min-w-0 flex-1 flex-col items-center justify-center px-[24px] pb-[2vh]'>
|
||||
<h1
|
||||
className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] tracking-[-0.02em]'
|
||||
style={{ color: C.TEXT_PRIMARY }}
|
||||
>
|
||||
What should we get done?
|
||||
</h1>
|
||||
|
||||
<div className='w-full max-w-[32rem]'>
|
||||
<div
|
||||
className='cursor-text rounded-[20px] border px-[10px] py-[8px]'
|
||||
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE }}
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder={animatedPlaceholder}
|
||||
rows={1}
|
||||
className='m-0 box-border min-h-[24px] w-full resize-none overflow-y-auto border-0 bg-transparent px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[#787878] focus-visible:ring-0'
|
||||
style={{
|
||||
color: C.TEXT_PRIMARY,
|
||||
caretColor: C.TEXT_PRIMARY,
|
||||
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 ? '#808080' : '#e0e0e0',
|
||||
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.25} color='#1b1b1b' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -8,6 +8,23 @@ import { createPortal } from 'react-dom'
|
||||
import { BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@/components/emcn'
|
||||
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||
|
||||
/**
|
||||
* Stores the prompt in browser storage and redirects to /signup.
|
||||
* Shared by both the copilot panel and the landing home view.
|
||||
*/
|
||||
export function useLandingSubmit() {
|
||||
const router = useRouter()
|
||||
return useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
LandingPromptStorage.store(trimmed)
|
||||
router.push('/signup')
|
||||
},
|
||||
[router]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight static panel replicating the real workspace panel styling.
|
||||
* The copilot tab is active with a functional user input.
|
||||
@@ -18,7 +35,7 @@ import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||
* inside Content > Copilot > header-bar(mx-[-1px]) > UserInput(p-8)
|
||||
*/
|
||||
export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
const router = useRouter()
|
||||
const landingSubmit = useLandingSubmit()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null)
|
||||
@@ -27,9 +44,8 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isEmpty) return
|
||||
LandingPromptStorage.store(inputValue)
|
||||
router.push('/signup')
|
||||
}, [isEmpty, inputValue, router])
|
||||
landingSubmit(inputValue)
|
||||
}, [isEmpty, inputValue, landingSubmit])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -60,10 +76,10 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
|
||||
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
|
||||
onMouseLeave={() => setCursorPos(null)}
|
||||
>
|
||||
<div className='flex h-[30px] items-center rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
|
||||
<div className='flex h-[30px] items-center rounded-[5px] bg-[#33C482] px-[10px] transition-colors hover:bg-[#2DAC72]'>
|
||||
<span className='font-medium text-[#1b1b1b] text-[12px]'>Deploy</span>
|
||||
</div>
|
||||
<div className='flex h-[30px] items-center gap-[8px] rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
|
||||
<div className='flex h-[30px] items-center gap-[8px] rounded-[5px] bg-[#33C482] px-[10px] transition-colors hover:bg-[#2DAC72]'>
|
||||
<Play className='h-[11.5px] w-[11.5px] text-[#1b1b1b]' />
|
||||
<span className='font-medium text-[#1b1b1b] text-[12px]'>Run</span>
|
||||
</div>
|
||||
|
||||
@@ -1,141 +1,204 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Database, Layout, Search, Settings } from 'lucide-react'
|
||||
import { ChevronDown, Library } from '@/components/emcn'
|
||||
import { ChevronDown, Home, Library } from '@/components/emcn'
|
||||
import {
|
||||
Calendar,
|
||||
Database,
|
||||
File,
|
||||
HelpCircle,
|
||||
Search,
|
||||
Settings,
|
||||
Table,
|
||||
} from '@/components/emcn/icons'
|
||||
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
|
||||
/**
|
||||
* Props for the LandingPreviewSidebar component
|
||||
*/
|
||||
interface LandingPreviewSidebarProps {
|
||||
workflows: PreviewWorkflow[]
|
||||
activeWorkflowId: string
|
||||
activeView: 'home' | 'workflow'
|
||||
onSelectWorkflow: (id: string) => void
|
||||
onSelectHome: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Static footer navigation items matching the real sidebar
|
||||
* Hardcoded dark-theme equivalents of the real sidebar CSS variables.
|
||||
* The preview lives inside a `dark` wrapper but CSS variable cascade
|
||||
* isn't guaranteed, so we pin the hex values directly.
|
||||
*/
|
||||
const FOOTER_NAV_ITEMS = [
|
||||
{ id: 'logs', label: 'Logs', icon: Library },
|
||||
{ id: 'templates', label: 'Templates', icon: Layout },
|
||||
const C = {
|
||||
SURFACE_1: '#1e1e1e',
|
||||
SURFACE_2: '#252525',
|
||||
SURFACE_ACTIVE: '#363636',
|
||||
BORDER: '#2c2c2c',
|
||||
TEXT_PRIMARY: '#e6e6e6',
|
||||
TEXT_BODY: '#cdcdcd',
|
||||
TEXT_ICON: '#939393',
|
||||
BRAND: '#33C482',
|
||||
} as const
|
||||
|
||||
const WORKSPACE_NAV = [
|
||||
{ id: 'tables', label: 'Tables', icon: Table },
|
||||
{ id: 'files', label: 'Files', icon: File },
|
||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: Database },
|
||||
{ id: 'scheduled-tasks', label: 'Scheduled Tasks', icon: Calendar },
|
||||
{ id: 'logs', label: 'Logs', icon: Library },
|
||||
] as const
|
||||
|
||||
const FOOTER_NAV = [
|
||||
{ id: 'help', label: 'Help', icon: HelpCircle },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
] as const
|
||||
|
||||
function StaticNavItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<div className='pointer-events-none mx-[2px] flex h-[28px] items-center gap-[8px] rounded-[8px] px-[8px]'>
|
||||
<Icon className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight static sidebar replicating the real workspace sidebar styling.
|
||||
* Lightweight sidebar replicating the real workspace sidebar layout and sizing.
|
||||
* Starts from the workspace header (no logo/collapse row).
|
||||
* Only workflow items are interactive — everything else is pointer-events-none.
|
||||
*
|
||||
* Colors sourced from the dark theme CSS variables:
|
||||
* --surface-1: #1e1e1e, --surface-5: #363636, --border: #2c2c2c, --border-1: #3d3d3d
|
||||
* --text-primary: #e6e6e6, --text-tertiary: #b3b3b3, --text-muted: #787878
|
||||
*/
|
||||
export function LandingPreviewSidebar({
|
||||
workflows,
|
||||
activeWorkflowId,
|
||||
activeView,
|
||||
onSelectWorkflow,
|
||||
onSelectHome,
|
||||
}: LandingPreviewSidebarProps) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setIsDropdownOpen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setIsDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isDropdownOpen])
|
||||
const isHomeActive = activeView === 'home'
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-[220px] flex-shrink-0 flex-col border-[#2c2c2c] border-r bg-[#1e1e1e]'>
|
||||
{/* Header */}
|
||||
<div className='relative flex-shrink-0 px-[14px] pt-[12px]' ref={dropdownRef}>
|
||||
<div className='flex items-center justify-between'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleToggle}
|
||||
className='group -mx-[6px] flex cursor-pointer items-center gap-[8px] rounded-[6px] bg-transparent px-[6px] py-[4px] transition-colors hover:bg-[#363636]'
|
||||
<div
|
||||
className='flex h-full w-[248px] flex-shrink-0 flex-col pt-[12px] tracking-[0.02em]'
|
||||
style={{ backgroundColor: C.SURFACE_1 }}
|
||||
>
|
||||
{/* Workspace Header */}
|
||||
<div className='flex-shrink-0 px-[10px]'>
|
||||
<div
|
||||
className='pointer-events-none flex h-[32px] w-full items-center gap-[8px] rounded-[8px] border pr-[8px] pl-[5px]'
|
||||
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE_2 }}
|
||||
>
|
||||
<div className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-white'>
|
||||
<svg width='10' height='10' viewBox='0 0 10 10' fill='none'>
|
||||
<path
|
||||
d='M1 9C1 4.58 4.58 1 9 1'
|
||||
stroke='#1e1e1e'
|
||||
strokeWidth='1.8'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
className='min-w-0 flex-1 truncate text-left font-medium text-[13px]'
|
||||
style={{ color: C.TEXT_PRIMARY }}
|
||||
>
|
||||
<span className='truncate font-base text-[#e6e6e6] text-[14px]'>My Workspace</span>
|
||||
<ChevronDown
|
||||
className={`h-[8px] w-[10px] flex-shrink-0 text-[#787878] transition-all duration-100 group-hover:text-[#cccccc] ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<div className='pointer-events-none flex flex-shrink-0 items-center'>
|
||||
<Search className='h-[14px] w-[14px] text-[#787878]' />
|
||||
Superark
|
||||
</span>
|
||||
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Navigation: Home (interactive), Search (static) */}
|
||||
<div className='mt-[10px] flex flex-shrink-0 flex-col gap-[2px] px-[8px]'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onSelectHome}
|
||||
className='mx-[2px] flex h-[28px] items-center gap-[8px] rounded-[8px] px-[8px] transition-colors'
|
||||
style={{ backgroundColor: isHomeActive ? C.SURFACE_ACTIVE : 'transparent' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isHomeActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isHomeActive) e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}}
|
||||
>
|
||||
<Home className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
|
||||
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
|
||||
Home
|
||||
</span>
|
||||
</button>
|
||||
<StaticNavItem icon={Search} label='Search' />
|
||||
</div>
|
||||
|
||||
{/* Workspace */}
|
||||
<div className='mt-[14px] flex flex-shrink-0 flex-col'>
|
||||
<div className='px-[16px] pb-[6px]'>
|
||||
<div className='font-base text-[12px]' style={{ color: C.TEXT_ICON }}>
|
||||
Workspace
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-[2px] px-[8px]'>
|
||||
{WORKSPACE_NAV.map((item) => (
|
||||
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workspace switcher dropdown */}
|
||||
{isDropdownOpen && (
|
||||
<div className='absolute top-[42px] left-[8px] z-50 min-w-[160px] max-w-[160px] rounded-[6px] bg-[#242424] px-[6px] py-[6px] shadow-lg'>
|
||||
<div
|
||||
className='flex h-[26px] cursor-pointer items-center gap-[8px] rounded-[6px] bg-[#3d3d3d] px-[6px] font-base text-[#e6e6e6] text-[13px]'
|
||||
role='menuitem'
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>My Workspace</span>
|
||||
{/* Scrollable Tasks + Workflows */}
|
||||
<div className='flex flex-1 flex-col overflow-y-auto overflow-x-hidden pt-[14px]'>
|
||||
{/* Workflows */}
|
||||
<div className='flex flex-col'>
|
||||
<div className='px-[16px]'>
|
||||
<div className='font-base text-[12px]' style={{ color: C.TEXT_ICON }}>
|
||||
Workflows
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow items */}
|
||||
<div className='mt-[8px] space-y-[2px] overflow-x-hidden px-[8px]'>
|
||||
{workflows.map((workflow) => {
|
||||
const isActive = workflow.id === activeWorkflowId
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type='button'
|
||||
onClick={() => onSelectWorkflow(workflow.id)}
|
||||
className={`group flex h-[26px] w-full items-center gap-[8px] rounded-[8px] px-[6px] text-[14px] transition-colors ${
|
||||
isActive ? 'bg-[#363636]' : 'bg-transparent hover:bg-[#363636]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px]'
|
||||
style={{ backgroundColor: workflow.color }}
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div
|
||||
className={`min-w-0 truncate text-left font-medium ${
|
||||
isActive ? 'text-[#e6e6e6]' : 'text-[#b3b3b3] group-hover:text-[#e6e6e6]'
|
||||
}`}
|
||||
<div className='mt-[6px] flex flex-col gap-[2px] px-[8px]'>
|
||||
{workflows.map((workflow) => {
|
||||
const isActive = activeView === 'workflow' && workflow.id === activeWorkflowId
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type='button'
|
||||
onClick={() => onSelectWorkflow(workflow.id)}
|
||||
className='group mx-[2px] flex h-[28px] w-full items-center gap-[8px] rounded-[8px] px-[8px] transition-colors'
|
||||
style={{ backgroundColor: isActive ? C.SURFACE_ACTIVE : 'transparent' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}}
|
||||
>
|
||||
{workflow.name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px] border-[2.5px]'
|
||||
style={{
|
||||
backgroundColor: workflow.color,
|
||||
borderColor: `${workflow.color}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='min-w-0 flex-1 truncate text-left text-[13px]'
|
||||
style={{ color: C.TEXT_BODY, fontWeight: 450 }}
|
||||
>
|
||||
{workflow.name}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer navigation — static */}
|
||||
<div className='pointer-events-none mt-auto flex flex-shrink-0 flex-col gap-[2px] border-[#2c2c2c] border-t px-[7.75px] pt-[8px] pb-[8px]'>
|
||||
{FOOTER_NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className='flex h-[26px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px]'
|
||||
>
|
||||
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[#b3b3b3]' />
|
||||
<span className='truncate font-medium text-[#b3b3b3] text-[13px]'>{item.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Footer */}
|
||||
<div className='flex flex-shrink-0 flex-col gap-[2px] px-[8px] pt-[9px] pb-[8px]'>
|
||||
{FOOTER_NAV.map((item) => (
|
||||
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { memo } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Database } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { Blimp } from '@/components/emcn'
|
||||
import {
|
||||
AgentIcon,
|
||||
AnthropicIcon,
|
||||
@@ -63,6 +64,7 @@ const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
|
||||
reducto: ReductoIcon,
|
||||
textract: TextractIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
mothership: Blimp,
|
||||
}
|
||||
|
||||
/** Model prefix → provider icon for the "Model" row in agent blocks. */
|
||||
|
||||
@@ -91,11 +91,11 @@ const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Content pipeline workflow — Schedule -> Agent (X + YouTube tools)
|
||||
* Self-healing CRM workflow — Schedule -> Mothership
|
||||
*/
|
||||
const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-content-pipeline',
|
||||
name: 'Content Pipeline',
|
||||
const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
|
||||
id: 'wf-self-healing-crm',
|
||||
name: 'Self-healing CRM',
|
||||
color: '#33C482',
|
||||
blocks: [
|
||||
{
|
||||
@@ -111,23 +111,16 @@ const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
|
||||
hideTargetHandle: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-2',
|
||||
name: 'Agent',
|
||||
type: 'agent',
|
||||
bgColor: '#701ffc',
|
||||
rows: [
|
||||
{ title: 'Model', value: 'grok-4' },
|
||||
{ title: 'System Prompt', value: 'Repurpose trending...' },
|
||||
],
|
||||
tools: [
|
||||
{ name: 'X', type: 'x', bgColor: '#000000' },
|
||||
{ name: 'YouTube', type: 'youtube', bgColor: '#FF0000' },
|
||||
],
|
||||
id: 'mothership-1',
|
||||
name: 'Update Agent',
|
||||
type: 'mothership',
|
||||
bgColor: '#33C482',
|
||||
rows: [{ title: 'Prompt', value: 'Audit CRM records, fix...' }],
|
||||
position: { x: 420, y: 180 },
|
||||
hideSourceHandle: true,
|
||||
},
|
||||
],
|
||||
edges: [{ id: 'e-3', source: 'schedule-1', target: 'agent-2' }],
|
||||
edges: [{ id: 'e-3', source: 'schedule-1', target: 'mothership-1' }],
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,7 +147,7 @@ const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
|
||||
}
|
||||
|
||||
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
|
||||
CONTENT_PIPELINE_WORKFLOW,
|
||||
SELF_HEALING_CRM_WORKFLOW,
|
||||
IT_SERVICE_WORKFLOW,
|
||||
NEW_AGENT_WORKFLOW,
|
||||
]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { motion, type Variants } from 'framer-motion'
|
||||
import { LandingPreviewHome } from '@/app/(home)/components/landing-preview/components/landing-preview-home/landing-preview-home'
|
||||
import { LandingPreviewPanel } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { LandingPreviewSidebar } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
|
||||
import { LandingPreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
|
||||
@@ -56,6 +57,7 @@ const panelVariants: Variants = {
|
||||
* load — workflow switches render instantly.
|
||||
*/
|
||||
export function LandingPreview() {
|
||||
const [activeView, setActiveView] = useState<'home' | 'workflow'>('workflow')
|
||||
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
|
||||
const isInitialMount = useRef(true)
|
||||
|
||||
@@ -63,12 +65,23 @@ export function LandingPreview() {
|
||||
isInitialMount.current = false
|
||||
}, [])
|
||||
|
||||
const handleSelectWorkflow = useCallback((id: string) => {
|
||||
setActiveWorkflowId(id)
|
||||
setActiveView('workflow')
|
||||
}, [])
|
||||
|
||||
const handleSelectHome = useCallback(() => {
|
||||
setActiveView('home')
|
||||
}, [])
|
||||
|
||||
const activeWorkflow =
|
||||
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
|
||||
|
||||
const isWorkflowView = activeView === 'workflow'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[#1b1b1b] antialiased'
|
||||
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[#1e1e1e] antialiased'
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
variants={containerVariants}
|
||||
@@ -77,15 +90,34 @@ export function LandingPreview() {
|
||||
<LandingPreviewSidebar
|
||||
workflows={PREVIEW_WORKFLOWS}
|
||||
activeWorkflowId={activeWorkflowId}
|
||||
onSelectWorkflow={setActiveWorkflowId}
|
||||
activeView={activeView}
|
||||
onSelectWorkflow={handleSelectWorkflow}
|
||||
onSelectHome={handleSelectHome}
|
||||
/>
|
||||
</motion.div>
|
||||
<div className='relative flex-1 overflow-hidden'>
|
||||
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
|
||||
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
|
||||
<div className='flex flex-1 overflow-hidden rounded-[8px] border border-[#2c2c2c] bg-[#1b1b1b]'>
|
||||
<div
|
||||
className={
|
||||
isWorkflowView
|
||||
? 'relative min-w-0 flex-1 overflow-hidden'
|
||||
: 'relative flex min-w-0 flex-1 flex-col overflow-hidden'
|
||||
}
|
||||
>
|
||||
{isWorkflowView ? (
|
||||
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
|
||||
) : (
|
||||
<LandingPreviewHome />
|
||||
)}
|
||||
</div>
|
||||
<motion.div
|
||||
className={isWorkflowView ? 'hidden lg:flex' : 'hidden'}
|
||||
variants={panelVariants}
|
||||
>
|
||||
<LandingPreviewPanel />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div className='hidden lg:flex' variants={panelVariants}>
|
||||
<LandingPreviewPanel />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ interface NavLink {
|
||||
}
|
||||
|
||||
const NAV_LINKS: NavLink[] = [
|
||||
{ label: 'Docs', href: '/docs', icon: 'chevron' },
|
||||
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Pricing', href: '/pricing' },
|
||||
{ label: 'Careers', href: '/careers' },
|
||||
{ label: 'Enterprise', href: '/enterprise' },
|
||||
@@ -86,7 +86,7 @@ export default function Navbar() {
|
||||
</Link>
|
||||
<Link
|
||||
href='/signup'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[9px] text-[13.5px] text-black transition-[filter] hover:brightness-110'
|
||||
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
aria-label='Get started with Sim'
|
||||
>
|
||||
Get started
|
||||
|
||||
@@ -123,7 +123,7 @@ function PricingCard({ tier }: PricingCardProps) {
|
||||
) : isPro ? (
|
||||
<Link
|
||||
href={tier.cta.href}
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-white transition-[filter] hover:brightness-110'
|
||||
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-[10px] font-[430] font-season text-[14px] text-white transition-colors hover:border-[#2A2A2A] hover:bg-[#2A2A2A]'
|
||||
>
|
||||
{tier.cta.label}
|
||||
</Link>
|
||||
@@ -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-8 sm:px-8 md:px-[80px]'>
|
||||
<div className='px-4 pt-[100px] pb-[80px] sm:px-8 md:px-[80px]'>
|
||||
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
|
||||
<Badge
|
||||
variant='blue'
|
||||
|
||||
@@ -337,7 +337,7 @@ function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols * rows }, (_, i) => (
|
||||
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
|
||||
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#2A2A2A]' />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -462,7 +462,7 @@ export default function Templates() {
|
||||
Ship your agent in minutes
|
||||
</h2>
|
||||
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[16px] leading-[125%] tracking-[0.02em]'>
|
||||
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
|
||||
Pre-built templates for every use case—pick one, swap <br />
|
||||
models and tools to fit your stack, and deploy.
|
||||
</p>
|
||||
@@ -557,7 +557,7 @@ export default function Templates() {
|
||||
type='button'
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isPreparingTemplate}
|
||||
className='group/cta absolute top-[16px] right-[16px] z-10 inline-flex h-[32px] cursor-pointer items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
|
||||
className='group/cta absolute top-[16px] right-[16px] z-10 inline-flex h-[32px] cursor-pointer items-center gap-[6px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
|
||||
>
|
||||
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
|
||||
<span className='relative h-[10px] w-[10px] shrink-0'>
|
||||
|
||||
@@ -41,6 +41,19 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes sidebar-collapse-guard {
|
||||
from {
|
||||
pointer-events: none;
|
||||
}
|
||||
to {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-container[data-collapsed] {
|
||||
animation: sidebar-collapse-guard 250ms step-end;
|
||||
}
|
||||
|
||||
.sidebar-container.is-resizing {
|
||||
transition: none;
|
||||
}
|
||||
@@ -145,7 +158,7 @@
|
||||
--brand-400: #8e4cfb;
|
||||
--brand-secondary: #33b4ff;
|
||||
--brand-tertiary: #22c55e;
|
||||
--brand-tertiary-2: #32bd7e;
|
||||
--brand-tertiary-2: #33c482;
|
||||
--selection: #1a5cf6;
|
||||
--warning: #ea580c;
|
||||
|
||||
@@ -267,7 +280,7 @@
|
||||
--brand-400: #8e4cfb;
|
||||
--brand-secondary: #33b4ff;
|
||||
--brand-tertiary: #22c55e;
|
||||
--brand-tertiary-2: #32bd7e;
|
||||
--brand-tertiary-2: #33c482;
|
||||
--selection: #4b83f7;
|
||||
--warning: #ff6600;
|
||||
|
||||
@@ -790,6 +803,59 @@ input[type="search"]::-ms-clear {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes notification-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(var(--stack-offset, 0px)) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes notification-countdown {
|
||||
from {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 34.56;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes notification-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(var(--stack-offset, 0px)) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
/* WandPromptBar status indicator */
|
||||
@keyframes smoke-pulse {
|
||||
0%,
|
||||
|
||||
@@ -171,7 +171,7 @@ export async function GET(request: NextRequest) {
|
||||
([category, templates]) => `
|
||||
<h2 style="margin-top: 24px; margin-bottom: 12px; font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">${category}</h2>
|
||||
<ul style="list-style: none; padding: 0; margin: 0;">
|
||||
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #32bd7e; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
|
||||
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #33C482; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
|
||||
</ul>
|
||||
`
|
||||
)
|
||||
|
||||
@@ -46,9 +46,10 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const formData = await request.formData()
|
||||
|
||||
const files = formData.getAll('file') as File[]
|
||||
const rawFiles = formData.getAll('file')
|
||||
const files = rawFiles.filter((f): f is File => f instanceof File)
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
if (files.length === 0) {
|
||||
throw new InvalidRequestError('No files provided')
|
||||
}
|
||||
|
||||
@@ -74,6 +75,9 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
for (const file of files) {
|
||||
const originalName = file.name
|
||||
if (!originalName) {
|
||||
throw new InvalidRequestError('File name is missing')
|
||||
}
|
||||
|
||||
if (!validateFileExtension(originalName)) {
|
||||
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'
|
||||
|
||||
@@ -87,32 +87,36 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File
|
||||
const rawFile = formData.get('file')
|
||||
|
||||
if (!file) {
|
||||
if (!rawFile || !(rawFile instanceof File)) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate file size (100MB limit)
|
||||
const fileName = rawFile.name
|
||||
if (!fileName) {
|
||||
return NextResponse.json({ error: 'File name is missing' }, { status: 400 })
|
||||
}
|
||||
|
||||
const maxSize = 100 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
if (rawFile.size > maxSize) {
|
||||
return NextResponse.json(
|
||||
{ error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` },
|
||||
{ error: `File size exceeds 100MB limit (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const buffer = Buffer.from(await rawFile.arrayBuffer())
|
||||
|
||||
const userFile = await uploadWorkspaceFile(
|
||||
workspaceId,
|
||||
session.user.id,
|
||||
buffer,
|
||||
file.name,
|
||||
file.type || 'application/octet-stream'
|
||||
fileName,
|
||||
rawFile.type || 'application/octet-stream'
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Uploaded workspace file: ${file.name}`)
|
||||
logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
@@ -122,8 +126,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
action: AuditAction.FILE_UPLOADED,
|
||||
resourceType: AuditResourceType.FILE,
|
||||
resourceId: userFile.id,
|
||||
resourceName: file.name,
|
||||
description: `Uploaded file "${file.name}"`,
|
||||
resourceName: fileName,
|
||||
description: `Uploaded file "${fileName}"`,
|
||||
request,
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ interface ThankYouScreenProps {
|
||||
}
|
||||
|
||||
/** Default green color matching --brand-tertiary-2 */
|
||||
const DEFAULT_THANK_YOU_COLOR = '#32bd7e'
|
||||
const DEFAULT_THANK_YOU_COLOR = '#33C482'
|
||||
|
||||
/** Legacy blue default that should be treated as "no custom color" */
|
||||
const LEGACY_BLUE_DEFAULT = '#3972F6'
|
||||
|
||||
@@ -1249,7 +1249,7 @@ export default function ResumeExecutionPage({
|
||||
{message && <Badge variant='green'>{message}</Badge>}
|
||||
|
||||
{/* Action */}
|
||||
<Button variant='tertiary' onClick={handleResume} disabled={resumeDisabled}>
|
||||
<Button variant='primary' onClick={handleResume} disabled={resumeDisabled}>
|
||||
{loadingAction ? 'Resuming...' : 'Resume Execution'}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -733,7 +733,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
<>
|
||||
{!currentUserId ? (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => {
|
||||
const callbackUrl =
|
||||
isWorkspaceContext && workspaceId
|
||||
@@ -749,7 +749,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
</Button>
|
||||
) : isWorkspaceContext ? (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleUseTemplate}
|
||||
disabled={isUsing}
|
||||
className='!text-[#FFFFFF] h-[32px] rounded-[6px] px-[12px] text-[14px]'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { MessageContent } from './message-content'
|
||||
export { MothershipView } from './mothership-view'
|
||||
export { QueuedMessages } from './queued-messages'
|
||||
export { TemplatePrompts } from './template-prompts'
|
||||
export { UserInput } from './user-input'
|
||||
export { UserMessageContent } from './user-message-content'
|
||||
|
||||
@@ -91,14 +91,8 @@ export function MothershipView({
|
||||
previewMode={isActivePreviewable ? previewMode : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[4px] px-[24px]'>
|
||||
<h2 className='font-semibold text-[20px] text-[var(--text-primary)]'>
|
||||
No resources open
|
||||
</h2>
|
||||
<p className='text-[12px] text-[var(--text-body)]'>
|
||||
Click the <span className='font-medium text-[var(--text-primary)]'>+</span> button
|
||||
above to add a resource to this task
|
||||
</p>
|
||||
<div className='flex h-full items-center justify-center text-[14px] text-[var(--text-muted)]'>
|
||||
Click "+" above to add a resource
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { QueuedMessages } from './queued-messages'
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ArrowUp, ChevronDown, ChevronRight, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import type { QueuedMessage } from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
interface QueuedMessagesProps {
|
||||
messageQueue: QueuedMessage[]
|
||||
onRemove: (id: string) => void
|
||||
onSendNow: (id: string) => Promise<void>
|
||||
onEdit: (id: string) => void
|
||||
}
|
||||
|
||||
export function QueuedMessages({ messageQueue, onRemove, onSendNow, onEdit }: QueuedMessagesProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
|
||||
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)]'>
|
||||
<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]'
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
) : (
|
||||
<ChevronRight className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
{messageQueue.length} Queued
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{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]'
|
||||
>
|
||||
<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' />
|
||||
</div>
|
||||
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='truncate text-[13px] text-[var(--text-primary)]'>{msg.content}</p>
|
||||
</div>
|
||||
|
||||
<div className='flex shrink-0 items-center gap-[2px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
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]'
|
||||
>
|
||||
<Pencil className='h-[13px] w-[13px]' />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' sideOffset={4}>
|
||||
Edit queued message
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
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]'
|
||||
>
|
||||
<ArrowUp className='h-[13px] w-[13px]' />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' sideOffset={4}>
|
||||
Send now
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
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]'
|
||||
>
|
||||
<Trash2 className='h-[13px] w-[13px]' />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' sideOffset={4}>
|
||||
Remove from queue
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -64,7 +64,10 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
||||
import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
|
||||
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
|
||||
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import type {
|
||||
FileAttachmentForApi,
|
||||
MothershipResource,
|
||||
} from '@/app/workspace/[workspaceId]/home/types'
|
||||
import {
|
||||
useContextManagement,
|
||||
useFileAttachments,
|
||||
@@ -125,9 +128,17 @@ function autoResizeTextarea(e: React.FormEvent<HTMLTextAreaElement>, maxHeight:
|
||||
function mapResourceToContext(resource: MothershipResource): ChatContext {
|
||||
switch (resource.type) {
|
||||
case 'workflow':
|
||||
return { kind: 'workflow', workflowId: resource.id, label: resource.title }
|
||||
return {
|
||||
kind: 'workflow',
|
||||
workflowId: resource.id,
|
||||
label: resource.title,
|
||||
}
|
||||
case 'knowledgebase':
|
||||
return { kind: 'knowledge', knowledgeId: resource.id, label: resource.title }
|
||||
return {
|
||||
kind: 'knowledge',
|
||||
knowledgeId: resource.id,
|
||||
label: resource.title,
|
||||
}
|
||||
case 'table':
|
||||
return { kind: 'table', tableId: resource.id, label: resource.title }
|
||||
case 'file':
|
||||
@@ -137,16 +148,12 @@ function mapResourceToContext(resource: MothershipResource): ChatContext {
|
||||
}
|
||||
}
|
||||
|
||||
export interface FileAttachmentForApi {
|
||||
id: string
|
||||
key: string
|
||||
filename: string
|
||||
media_type: string
|
||||
size: number
|
||||
}
|
||||
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
interface UserInputProps {
|
||||
defaultValue?: string
|
||||
editValue?: string
|
||||
onEditValueConsumed?: () => void
|
||||
onSubmit: (
|
||||
text: string,
|
||||
fileAttachments?: FileAttachmentForApi[],
|
||||
@@ -161,6 +168,8 @@ interface UserInputProps {
|
||||
|
||||
export function UserInput({
|
||||
defaultValue = '',
|
||||
editValue,
|
||||
onEditValueConsumed,
|
||||
onSubmit,
|
||||
isSending,
|
||||
onStopGeneration,
|
||||
@@ -176,9 +185,27 @@ export function UserInput({
|
||||
const [plusMenuActiveIndex, setPlusMenuActiveIndex] = useState(0)
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [prevDefaultValue, setPrevDefaultValue] = useState(defaultValue)
|
||||
if (defaultValue && defaultValue !== prevDefaultValue) {
|
||||
setPrevDefaultValue(defaultValue)
|
||||
setValue(defaultValue)
|
||||
} else if (!defaultValue && prevDefaultValue) {
|
||||
setPrevDefaultValue(defaultValue)
|
||||
}
|
||||
|
||||
const [prevEditValue, setPrevEditValue] = useState(editValue)
|
||||
if (editValue && editValue !== prevEditValue) {
|
||||
setPrevEditValue(editValue)
|
||||
setValue(editValue)
|
||||
} else if (!editValue && prevEditValue) {
|
||||
setPrevEditValue(editValue)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultValue) setValue(defaultValue)
|
||||
}, [defaultValue])
|
||||
if (editValue) {
|
||||
onEditValueConsumed?.()
|
||||
}
|
||||
}, [editValue, onEditValueConsumed])
|
||||
|
||||
const animatedPlaceholder = useAnimatedPlaceholder(isInitialView)
|
||||
const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
|
||||
@@ -393,9 +420,7 @@ export function UserInput({
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
if (!isSending) {
|
||||
handleSubmit()
|
||||
}
|
||||
handleSubmit()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -461,7 +486,7 @@ export function UserInput({
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSubmit, isSending, mentionTokensWithContext, value, textareaRef]
|
||||
[handleSubmit, mentionTokensWithContext, value, textareaRef]
|
||||
)
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
@@ -637,7 +662,9 @@ export function UserInput({
|
||||
<span
|
||||
key={`mention-${i}-${range.start}-${range.end}`}
|
||||
className='rounded-[5px] bg-[var(--surface-5)] py-[2px]'
|
||||
style={{ boxShadow: '-2px 0 0 var(--surface-5), 2px 0 0 var(--surface-5)' }}
|
||||
style={{
|
||||
boxShadow: '-2px 0 0 var(--surface-5), 2px 0 0 var(--surface-5)',
|
||||
}}
|
||||
>
|
||||
<span className='relative'>
|
||||
<span className='invisible'>{range.token.charAt(0)}</span>
|
||||
@@ -662,7 +689,7 @@ export function UserInput({
|
||||
<div
|
||||
onClick={handleContainerClick}
|
||||
className={cn(
|
||||
'relative mx-auto w-full max-w-[42rem] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-[10px] py-[8px] dark:bg-[var(--surface-4)]',
|
||||
'relative z-10 mx-auto w-full max-w-[42rem] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-[10px] py-[8px] dark:bg-[var(--surface-4)]',
|
||||
isInitialView && 'shadow-sm'
|
||||
)}
|
||||
onDragEnter={files.handleDragEnter}
|
||||
@@ -818,7 +845,11 @@ export function UserInput({
|
||||
)}
|
||||
onMouseEnter={() => setPlusMenuActiveIndex(index)}
|
||||
onClick={() => {
|
||||
handleResourceSelect({ type, id: item.id, title: item.name })
|
||||
handleResourceSelect({
|
||||
type,
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
})
|
||||
setPlusMenuOpen(false)
|
||||
setPlusMenuSearch('')
|
||||
setPlusMenuActiveIndex(0)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
@@ -19,14 +19,14 @@ import type { ChatContext } from '@/stores/panel'
|
||||
import {
|
||||
MessageContent,
|
||||
MothershipView,
|
||||
QueuedMessages,
|
||||
TemplatePrompts,
|
||||
UserInput,
|
||||
UserMessageContent,
|
||||
} from './components'
|
||||
import { PendingTagIndicator } from './components/message-content/components/special-tags'
|
||||
import type { FileAttachmentForApi } from './components/user-input/user-input'
|
||||
import { useAutoScroll, useChat } from './hooks'
|
||||
import type { MothershipResource, MothershipResourceType } from './types'
|
||||
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
|
||||
|
||||
const logger = createLogger('Home')
|
||||
|
||||
@@ -183,8 +183,29 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
addResource,
|
||||
removeResource,
|
||||
reorderResources,
|
||||
messageQueue,
|
||||
removeFromQueue,
|
||||
sendNow,
|
||||
editQueuedMessage,
|
||||
} = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent })
|
||||
|
||||
const [editingInputValue, setEditingInputValue] = useState('')
|
||||
const clearEditingValue = useCallback(() => setEditingInputValue(''), [])
|
||||
|
||||
const handleEditQueuedMessage = useCallback(
|
||||
(id: string) => {
|
||||
const msg = editQueuedMessage(id)
|
||||
if (msg) {
|
||||
setEditingInputValue(msg.content)
|
||||
}
|
||||
},
|
||||
[editQueuedMessage]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setEditingInputValue('')
|
||||
}, [chatId])
|
||||
|
||||
useEffect(() => {
|
||||
wasSendingRef.current = false
|
||||
if (resolvedChatId) markRead(resolvedChatId)
|
||||
@@ -272,9 +293,22 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
[addResource, handleResourceEvent]
|
||||
)
|
||||
|
||||
const scrollContainerRef = useAutoScroll(isSending)
|
||||
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isSending)
|
||||
|
||||
const hasMessages = messages.length > 0
|
||||
const initialScrollDoneRef = useRef(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hasMessages) {
|
||||
initialScrollDoneRef.current = false
|
||||
return
|
||||
}
|
||||
if (initialScrollDoneRef.current) return
|
||||
if (resources.length > 0 && isResourceCollapsed) return
|
||||
|
||||
initialScrollDoneRef.current = true
|
||||
scrollToBottom()
|
||||
}, [hasMessages, resources.length, isResourceCollapsed, scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasMessages) return
|
||||
@@ -309,7 +343,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
|
||||
if (!hasMessages) {
|
||||
return (
|
||||
<div className='h-full overflow-y-auto bg-[var(--bg)]'>
|
||||
<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]'>
|
||||
<h1 className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] text-[var(--text-primary)] tracking-[-0.02em]'>
|
||||
What should we get done
|
||||
@@ -338,7 +372,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
<div className='flex h-full min-w-0 flex-1 flex-col'>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8'
|
||||
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]'
|
||||
>
|
||||
<div className='mx-auto max-w-[42rem] space-y-6'>
|
||||
{messages.map((msg, index) => {
|
||||
@@ -406,6 +440,12 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
|
||||
<div className='flex-shrink-0 px-[24px] pb-[16px]'>
|
||||
<div className='mx-auto max-w-[42rem]'>
|
||||
<QueuedMessages
|
||||
messageQueue={messageQueue}
|
||||
onRemove={removeFromQueue}
|
||||
onSendNow={sendNow}
|
||||
onEdit={handleEditQueuedMessage}
|
||||
/>
|
||||
<UserInput
|
||||
onSubmit={handleSubmit}
|
||||
isSending={isSending}
|
||||
@@ -413,6 +453,8 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
isInitialView={false}
|
||||
userId={session?.user?.id}
|
||||
onContextAdd={handleContextAdd}
|
||||
editValue={editingInputValue}
|
||||
onEditValueConsumed={clearEditingValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,9 @@ const REATTACH_THRESHOLD = 5
|
||||
* on any upward user gesture (wheel, touch, scrollbar drag). Once detached,
|
||||
* the user must scroll back to within {@link REATTACH_THRESHOLD} of the
|
||||
* bottom to re-engage.
|
||||
*
|
||||
* Returns `ref` (callback ref for the scroll container) and `scrollToBottom`
|
||||
* for imperative use after layout-changing events like panel expansion.
|
||||
*/
|
||||
export function useAutoScroll(isStreaming: boolean) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -110,5 +113,5 @@ export function useAutoScroll(isStreaming: boolean) {
|
||||
}
|
||||
}, [isStreaming, scrollToBottom])
|
||||
|
||||
return callbackRef
|
||||
return { ref: callbackRef, scrollToBottom }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { usePathname } from 'next/navigation'
|
||||
@@ -28,14 +28,15 @@ import { useFolderStore } from '@/stores/folders/store'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { useTerminalConsoleStore } from '@/stores/terminal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { FileAttachmentForApi } from '../components/user-input/user-input'
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatMessageAttachment,
|
||||
ContentBlock,
|
||||
ContentBlockType,
|
||||
FileAttachmentForApi,
|
||||
MothershipResource,
|
||||
MothershipResourceType,
|
||||
QueuedMessage,
|
||||
SSEPayload,
|
||||
SSEPayloadData,
|
||||
ToolCallStatus,
|
||||
@@ -58,6 +59,10 @@ export interface UseChatReturn {
|
||||
addResource: (resource: MothershipResource) => boolean
|
||||
removeResource: (resourceType: MothershipResourceType, resourceId: string) => void
|
||||
reorderResources: (resources: MothershipResource[]) => void
|
||||
messageQueue: QueuedMessage[]
|
||||
removeFromQueue: (id: string) => void
|
||||
sendNow: (id: string) => Promise<void>
|
||||
editQueuedMessage: (id: string) => QueuedMessage | undefined
|
||||
}
|
||||
|
||||
const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
|
||||
@@ -101,7 +106,11 @@ function mapStoredToolCall(tc: TaskStoredToolCall): ContentBlock {
|
||||
displayTitle: resolvedStatus === 'cancelled' ? 'Stopped by user' : undefined,
|
||||
result:
|
||||
tc.result != null
|
||||
? { success: tc.status === 'success', output: tc.result, error: tc.error }
|
||||
? {
|
||||
success: tc.status === 'success',
|
||||
output: tc.result,
|
||||
error: tc.error,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
@@ -252,6 +261,14 @@ export function useChat(
|
||||
const activeResourceIdRef = useRef(activeResourceId)
|
||||
activeResourceIdRef.current = activeResourceId
|
||||
|
||||
const [messageQueue, setMessageQueue] = useState<QueuedMessage[]>([])
|
||||
const messageQueueRef = useRef<QueuedMessage[]>([])
|
||||
useEffect(() => {
|
||||
messageQueueRef.current = messageQueue
|
||||
}, [messageQueue])
|
||||
|
||||
const sendMessageRef = useRef<UseChatReturn['sendMessage']>(async () => {})
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const chatIdRef = useRef<string | undefined>(initialChatId)
|
||||
const appliedChatIdRef = useRef<string | undefined>(undefined)
|
||||
@@ -303,6 +320,7 @@ export function useChat(
|
||||
if (sendingRef.current) {
|
||||
chatIdRef.current = initialChatId
|
||||
setResolvedChatId(initialChatId)
|
||||
setMessageQueue([])
|
||||
return
|
||||
}
|
||||
chatIdRef.current = initialChatId
|
||||
@@ -313,6 +331,7 @@ export function useChat(
|
||||
setIsSending(false)
|
||||
setResources([])
|
||||
setActiveResourceId(null)
|
||||
setMessageQueue([])
|
||||
}, [initialChatId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -329,6 +348,7 @@ export function useChat(
|
||||
setIsSending(false)
|
||||
setResources([])
|
||||
setActiveResourceId(null)
|
||||
setMessageQueue([])
|
||||
}, [isHomePage])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -419,7 +439,9 @@ export function useChat(
|
||||
const isNewChat = !chatIdRef.current
|
||||
chatIdRef.current = parsed.chatId
|
||||
setResolvedChatId(parsed.chatId)
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: taskKeys.list(workspaceId),
|
||||
})
|
||||
if (isNewChat) {
|
||||
const userMsg = pendingUserMsgRef.current
|
||||
const activeStreamId = streamIdRef.current
|
||||
@@ -427,7 +449,13 @@ export function useChat(
|
||||
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(parsed.chatId), {
|
||||
id: parsed.chatId,
|
||||
title: null,
|
||||
messages: [{ id: userMsg.id, role: 'user', content: userMsg.content }],
|
||||
messages: [
|
||||
{
|
||||
id: userMsg.id,
|
||||
role: 'user',
|
||||
content: userMsg.content,
|
||||
},
|
||||
],
|
||||
activeStreamId,
|
||||
resources: [],
|
||||
})
|
||||
@@ -619,7 +647,9 @@ export function useChat(
|
||||
break
|
||||
}
|
||||
case 'title_updated': {
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: taskKeys.list(workspaceId),
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'error': {
|
||||
@@ -689,17 +719,37 @@ export function useChat(
|
||||
const invalidateChatQueries = useCallback(() => {
|
||||
const activeChatId = chatIdRef.current
|
||||
if (activeChatId) {
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.detail(activeChatId) })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: taskKeys.detail(activeChatId),
|
||||
})
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
|
||||
}, [workspaceId, queryClient])
|
||||
|
||||
const finalize = useCallback(() => {
|
||||
sendingRef.current = false
|
||||
setIsSending(false)
|
||||
abortControllerRef.current = null
|
||||
invalidateChatQueries()
|
||||
}, [invalidateChatQueries])
|
||||
const finalize = useCallback(
|
||||
(options?: { error?: boolean }) => {
|
||||
sendingRef.current = false
|
||||
setIsSending(false)
|
||||
abortControllerRef.current = null
|
||||
invalidateChatQueries()
|
||||
|
||||
if (options?.error) {
|
||||
setMessageQueue([])
|
||||
return
|
||||
}
|
||||
|
||||
const next = messageQueueRef.current[0]
|
||||
if (next) {
|
||||
setMessageQueue((prev) => prev.filter((m) => m.id !== next.id))
|
||||
const gen = streamGenRef.current
|
||||
queueMicrotask(() => {
|
||||
if (streamGenRef.current !== gen) return
|
||||
sendMessageRef.current(next.content, next.fileAttachments, next.contexts)
|
||||
})
|
||||
}
|
||||
},
|
||||
[invalidateChatQueries]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const activeStreamId = chatHistory?.activeStreamId
|
||||
@@ -714,7 +764,12 @@ export function useChat(
|
||||
const assistantId = crypto.randomUUID()
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: assistantId, role: 'assistant' as const, content: '', contentBlocks: [] },
|
||||
{
|
||||
id: assistantId,
|
||||
role: 'assistant' as const,
|
||||
content: '',
|
||||
contentBlocks: [],
|
||||
},
|
||||
])
|
||||
|
||||
const reconnect = async () => {
|
||||
@@ -745,9 +800,15 @@ export function useChat(
|
||||
if (!message.trim() || !workspaceId) return
|
||||
|
||||
if (sendingRef.current) {
|
||||
await persistPartialResponse()
|
||||
const queued: QueuedMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
content: message,
|
||||
fileAttachments,
|
||||
contexts,
|
||||
}
|
||||
setMessageQueue((prev) => [...prev, queued])
|
||||
return
|
||||
}
|
||||
abortControllerRef.current?.abort()
|
||||
|
||||
const gen = ++streamGenRef.current
|
||||
|
||||
@@ -854,14 +915,20 @@ export function useChat(
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
setError(err instanceof Error ? err.message : 'Failed to send message')
|
||||
} finally {
|
||||
if (streamGenRef.current === gen) {
|
||||
finalize()
|
||||
finalize({ error: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (streamGenRef.current === gen) {
|
||||
finalize()
|
||||
}
|
||||
},
|
||||
[workspaceId, queryClient, processSSEStream, finalize, persistPartialResponse]
|
||||
[workspaceId, queryClient, processSSEStream, finalize]
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
sendMessageRef.current = sendMessage
|
||||
})
|
||||
|
||||
const stopGeneration = useCallback(async () => {
|
||||
if (sendingRef.current) {
|
||||
@@ -943,6 +1010,32 @@ export function useChat(
|
||||
}
|
||||
}, [invalidateChatQueries, persistPartialResponse, executionStream])
|
||||
|
||||
const removeFromQueue = useCallback((id: string) => {
|
||||
messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
|
||||
setMessageQueue((prev) => prev.filter((m) => m.id !== id))
|
||||
}, [])
|
||||
|
||||
const sendNow = useCallback(
|
||||
async (id: string) => {
|
||||
const msg = messageQueueRef.current.find((m) => m.id === id)
|
||||
if (!msg) return
|
||||
// Eagerly update ref so a rapid second click finds the message already gone
|
||||
messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
|
||||
await stopGeneration()
|
||||
setMessageQueue((prev) => prev.filter((m) => m.id !== id))
|
||||
await sendMessage(msg.content, msg.fileAttachments, msg.contexts)
|
||||
},
|
||||
[stopGeneration, sendMessage]
|
||||
)
|
||||
|
||||
const editQueuedMessage = useCallback((id: string): QueuedMessage | undefined => {
|
||||
const msg = messageQueueRef.current.find((m) => m.id === id)
|
||||
if (!msg) return undefined
|
||||
messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
|
||||
setMessageQueue((prev) => prev.filter((m) => m.id !== id))
|
||||
return msg
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamGenRef.current++
|
||||
@@ -968,5 +1061,9 @@ export function useChat(
|
||||
addResource,
|
||||
removeResource,
|
||||
reorderResources,
|
||||
messageQueue,
|
||||
removeFromQueue,
|
||||
sendNow,
|
||||
editQueuedMessage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import type { MothershipResourceType } from '@/lib/copilot/resource-types'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
export type { MothershipResource, MothershipResourceType } from '@/lib/copilot/resource-types'
|
||||
export type {
|
||||
MothershipResource,
|
||||
MothershipResourceType,
|
||||
} from '@/lib/copilot/resource-types'
|
||||
|
||||
export interface FileAttachmentForApi {
|
||||
id: string
|
||||
key: string
|
||||
filename: string
|
||||
media_type: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface QueuedMessage {
|
||||
id: string
|
||||
content: string
|
||||
fileAttachments?: FileAttachmentForApi[]
|
||||
contexts?: ChatContext[]
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE event types emitted by the Go orchestrator backend.
|
||||
@@ -16,21 +35,21 @@ export type SSEEventType =
|
||||
| 'chat_id'
|
||||
| 'title_updated'
|
||||
| 'content'
|
||||
| 'reasoning'
|
||||
| 'tool_call'
|
||||
| 'tool_call_delta'
|
||||
| 'tool_generating'
|
||||
| 'tool_result'
|
||||
| 'tool_error'
|
||||
| 'resource_added'
|
||||
| 'resource_deleted'
|
||||
| 'subagent_start'
|
||||
| 'subagent_end'
|
||||
| 'structured_result'
|
||||
| 'subagent_result'
|
||||
| 'done'
|
||||
| 'error'
|
||||
| 'start'
|
||||
| 'reasoning' // openai reasoning - render as thinking text
|
||||
| 'tool_call' // tool call name
|
||||
| 'tool_call_delta' // chunk of tool call
|
||||
| 'tool_generating' // start a tool call
|
||||
| 'tool_result' // tool call result
|
||||
| 'tool_error' // tool call error
|
||||
| 'resource_added' // add a resource to the chat
|
||||
| 'resource_deleted' // delete a resource from the chat
|
||||
| 'subagent_start' // start a subagent
|
||||
| 'subagent_end' // end a subagent
|
||||
| 'structured_result' // structured result from a tool call
|
||||
| 'subagent_result' // result from a subagent
|
||||
| 'done' // end of the chat
|
||||
| 'error' // error in the chat
|
||||
| 'start' // start of the chat
|
||||
|
||||
/**
|
||||
* All tool names observed in the mothership SSE stream, grouped by phase.
|
||||
@@ -203,38 +222,122 @@ export interface ToolUIMetadata {
|
||||
* fallback metadata for tools that arrive via `tool_generating` without `ui`.
|
||||
*/
|
||||
export const TOOL_UI_METADATA: Partial<Record<MothershipToolName, ToolUIMetadata>> = {
|
||||
glob: { title: 'Searching files', phaseLabel: 'Workspace', phase: 'workspace' },
|
||||
grep: { title: 'Searching code', phaseLabel: 'Workspace', phase: 'workspace' },
|
||||
glob: {
|
||||
title: 'Searching files',
|
||||
phaseLabel: 'Workspace',
|
||||
phase: 'workspace',
|
||||
},
|
||||
grep: {
|
||||
title: 'Searching code',
|
||||
phaseLabel: 'Workspace',
|
||||
phase: 'workspace',
|
||||
},
|
||||
read: { title: 'Reading file', phaseLabel: 'Workspace', phase: 'workspace' },
|
||||
search_online: { title: 'Searching online', phaseLabel: 'Search', phase: 'search' },
|
||||
scrape_page: { title: 'Scraping page', phaseLabel: 'Search', phase: 'search' },
|
||||
get_page_contents: { title: 'Getting page contents', phaseLabel: 'Search', phase: 'search' },
|
||||
search_library_docs: { title: 'Searching library docs', phaseLabel: 'Search', phase: 'search' },
|
||||
manage_mcp_tool: { title: 'Managing MCP tool', phaseLabel: 'Management', phase: 'management' },
|
||||
manage_skill: { title: 'Managing skill', phaseLabel: 'Management', phase: 'management' },
|
||||
user_memory: { title: 'Accessing memory', phaseLabel: 'Management', phase: 'management' },
|
||||
function_execute: { title: 'Running code', phaseLabel: 'Code', phase: 'execution' },
|
||||
superagent: { title: 'Executing action', phaseLabel: 'Action', phase: 'execution' },
|
||||
user_table: { title: 'Managing table', phaseLabel: 'Resource', phase: 'resource' },
|
||||
workspace_file: { title: 'Managing file', phaseLabel: 'Resource', phase: 'resource' },
|
||||
create_workflow: { title: 'Creating workflow', phaseLabel: 'Resource', phase: 'resource' },
|
||||
edit_workflow: { title: 'Editing workflow', phaseLabel: 'Resource', phase: 'resource' },
|
||||
search_online: {
|
||||
title: 'Searching online',
|
||||
phaseLabel: 'Search',
|
||||
phase: 'search',
|
||||
},
|
||||
scrape_page: {
|
||||
title: 'Scraping page',
|
||||
phaseLabel: 'Search',
|
||||
phase: 'search',
|
||||
},
|
||||
get_page_contents: {
|
||||
title: 'Getting page contents',
|
||||
phaseLabel: 'Search',
|
||||
phase: 'search',
|
||||
},
|
||||
search_library_docs: {
|
||||
title: 'Searching library docs',
|
||||
phaseLabel: 'Search',
|
||||
phase: 'search',
|
||||
},
|
||||
manage_mcp_tool: {
|
||||
title: 'Managing MCP tool',
|
||||
phaseLabel: 'Management',
|
||||
phase: 'management',
|
||||
},
|
||||
manage_skill: {
|
||||
title: 'Managing skill',
|
||||
phaseLabel: 'Management',
|
||||
phase: 'management',
|
||||
},
|
||||
user_memory: {
|
||||
title: 'Accessing memory',
|
||||
phaseLabel: 'Management',
|
||||
phase: 'management',
|
||||
},
|
||||
function_execute: {
|
||||
title: 'Running code',
|
||||
phaseLabel: 'Code',
|
||||
phase: 'execution',
|
||||
},
|
||||
superagent: {
|
||||
title: 'Executing action',
|
||||
phaseLabel: 'Action',
|
||||
phase: 'execution',
|
||||
},
|
||||
user_table: {
|
||||
title: 'Managing table',
|
||||
phaseLabel: 'Resource',
|
||||
phase: 'resource',
|
||||
},
|
||||
workspace_file: {
|
||||
title: 'Managing file',
|
||||
phaseLabel: 'Resource',
|
||||
phase: 'resource',
|
||||
},
|
||||
create_workflow: {
|
||||
title: 'Creating workflow',
|
||||
phaseLabel: 'Resource',
|
||||
phase: 'resource',
|
||||
},
|
||||
edit_workflow: {
|
||||
title: 'Editing workflow',
|
||||
phaseLabel: 'Resource',
|
||||
phase: 'resource',
|
||||
},
|
||||
build: { title: 'Building', phaseLabel: 'Build', phase: 'subagent' },
|
||||
run: { title: 'Running', phaseLabel: 'Run', phase: 'subagent' },
|
||||
deploy: { title: 'Deploying', phaseLabel: 'Deploy', phase: 'subagent' },
|
||||
auth: { title: 'Connecting credentials', phaseLabel: 'Auth', phase: 'subagent' },
|
||||
knowledge: { title: 'Managing knowledge', phaseLabel: 'Knowledge', phase: 'subagent' },
|
||||
knowledge_base: { title: 'Managing knowledge base', phaseLabel: 'Resource', phase: 'resource' },
|
||||
auth: {
|
||||
title: 'Connecting credentials',
|
||||
phaseLabel: 'Auth',
|
||||
phase: 'subagent',
|
||||
},
|
||||
knowledge: {
|
||||
title: 'Managing knowledge',
|
||||
phaseLabel: 'Knowledge',
|
||||
phase: 'subagent',
|
||||
},
|
||||
knowledge_base: {
|
||||
title: 'Managing knowledge base',
|
||||
phaseLabel: 'Resource',
|
||||
phase: 'resource',
|
||||
},
|
||||
table: { title: 'Managing tables', phaseLabel: 'Table', phase: 'subagent' },
|
||||
job: { title: 'Managing jobs', phaseLabel: 'Job', phase: 'subagent' },
|
||||
agent: { title: 'Agent action', phaseLabel: 'Agent', phase: 'subagent' },
|
||||
custom_tool: { title: 'Creating tool', phaseLabel: 'Tool', phase: 'subagent' },
|
||||
custom_tool: {
|
||||
title: 'Creating tool',
|
||||
phaseLabel: 'Tool',
|
||||
phase: 'subagent',
|
||||
},
|
||||
research: { title: 'Researching', phaseLabel: 'Research', phase: 'subagent' },
|
||||
plan: { title: 'Planning', phaseLabel: 'Plan', phase: 'subagent' },
|
||||
debug: { title: 'Debugging', phaseLabel: 'Debug', phase: 'subagent' },
|
||||
edit: { title: 'Editing workflow', phaseLabel: 'Edit', phase: 'subagent' },
|
||||
fast_edit: { title: 'Editing workflow', phaseLabel: 'Edit', phase: 'subagent' },
|
||||
open_resource: { title: 'Opening resource', phaseLabel: 'Resource', phase: 'resource' },
|
||||
fast_edit: {
|
||||
title: 'Editing workflow',
|
||||
phaseLabel: 'Edit',
|
||||
phase: 'subagent',
|
||||
},
|
||||
open_resource: {
|
||||
title: 'Opening resource',
|
||||
phaseLabel: 'Resource',
|
||||
phase: 'resource',
|
||||
},
|
||||
}
|
||||
|
||||
export interface SSEPayloadUI {
|
||||
|
||||
@@ -554,7 +554,7 @@ export function DocumentTagsModal({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={saveDocumentTag}
|
||||
className='flex-1'
|
||||
disabled={!canSaveTag}
|
||||
@@ -718,7 +718,7 @@ export function DocumentTagsModal({
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={saveDocumentTag}
|
||||
className='flex-1'
|
||||
disabled={
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { ArrowLeft, Loader2, Plus, Search } from 'lucide-react'
|
||||
import { ArrowLeft, ArrowLeftRight, Loader2, Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import {
|
||||
@@ -24,11 +25,14 @@ import {
|
||||
getProviderIdFromServiceId,
|
||||
type OAuthProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
import type { ConnectorConfig } from '@/connectors/types'
|
||||
import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types'
|
||||
import { useCreateConnector } from '@/hooks/queries/kb/connectors'
|
||||
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
|
||||
import type { SelectorKey } from '@/hooks/selectors/types'
|
||||
|
||||
const SYNC_INTERVALS = [
|
||||
{ label: 'Every hour', value: 60 },
|
||||
@@ -38,6 +42,8 @@ const SYNC_INTERVALS = [
|
||||
{ label: 'Manual only', value: 0 },
|
||||
] as const
|
||||
|
||||
const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_REGISTRY)
|
||||
|
||||
interface AddConnectorModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -55,8 +61,10 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
const [disabledTagIds, setDisabledTagIds] = useState<Set<string>>(() => new Set())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [canonicalModes, setCanonicalModes] = useState<Record<string, 'basic' | 'advanced'>>({})
|
||||
|
||||
const [apiKeyValue, setApiKeyValue] = useState('')
|
||||
const [apiKeyFocused, setApiKeyFocused] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
@@ -81,17 +89,126 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
const effectiveCredentialId =
|
||||
selectedCredentialId ?? (credentials.length === 1 ? credentials[0].id : null)
|
||||
|
||||
const canonicalGroups = useMemo(() => {
|
||||
if (!connectorConfig) return new Map<string, ConnectorConfigField[]>()
|
||||
const groups = new Map<string, ConnectorConfigField[]>()
|
||||
for (const field of connectorConfig.configFields) {
|
||||
if (field.canonicalParamId) {
|
||||
const existing = groups.get(field.canonicalParamId)
|
||||
if (existing) {
|
||||
existing.push(field)
|
||||
} else {
|
||||
groups.set(field.canonicalParamId, [field])
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}, [connectorConfig])
|
||||
|
||||
const dependentFieldIds = useMemo(() => {
|
||||
if (!connectorConfig) return new Map<string, string[]>()
|
||||
const map = new Map<string, string[]>()
|
||||
for (const field of connectorConfig.configFields) {
|
||||
const deps = getDependsOnFields(field.dependsOn)
|
||||
for (const dep of deps) {
|
||||
const existing = map.get(dep) ?? []
|
||||
existing.push(field.id)
|
||||
map.set(dep, existing)
|
||||
}
|
||||
}
|
||||
for (const group of canonicalGroups.values()) {
|
||||
const allDependents = new Set<string>()
|
||||
for (const field of group) {
|
||||
for (const dep of map.get(field.id) ?? []) {
|
||||
allDependents.add(dep)
|
||||
const depField = connectorConfig.configFields.find((f) => f.id === dep)
|
||||
if (depField?.canonicalParamId) {
|
||||
for (const sibling of canonicalGroups.get(depField.canonicalParamId) ?? []) {
|
||||
allDependents.add(sibling.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (allDependents.size > 0) {
|
||||
for (const field of group) {
|
||||
map.set(field.id, [...allDependents])
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [connectorConfig, canonicalGroups])
|
||||
|
||||
const handleSelectType = (type: string) => {
|
||||
setSelectedType(type)
|
||||
setSourceConfig({})
|
||||
setSelectedCredentialId(null)
|
||||
setApiKeyValue('')
|
||||
setApiKeyFocused(false)
|
||||
setDisabledTagIds(new Set())
|
||||
setCanonicalModes({})
|
||||
setError(null)
|
||||
setSearchTerm('')
|
||||
setStep('configure')
|
||||
}
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(fieldId: string, value: string) => {
|
||||
setSourceConfig((prev) => {
|
||||
const next = { ...prev, [fieldId]: value }
|
||||
const toClear = dependentFieldIds.get(fieldId)
|
||||
if (toClear) {
|
||||
for (const depId of toClear) {
|
||||
next[depId] = ''
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[dependentFieldIds]
|
||||
)
|
||||
|
||||
const toggleCanonicalMode = useCallback((canonicalId: string) => {
|
||||
setCanonicalModes((prev) => ({
|
||||
...prev,
|
||||
[canonicalId]: prev[canonicalId] === 'advanced' ? 'basic' : 'advanced',
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const isFieldVisible = useCallback(
|
||||
(field: ConnectorConfigField): boolean => {
|
||||
if (!field.canonicalParamId || !field.mode) return true
|
||||
const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic'
|
||||
return field.mode === activeMode
|
||||
},
|
||||
[canonicalModes]
|
||||
)
|
||||
|
||||
const resolveSourceConfig = useCallback((): Record<string, string> => {
|
||||
const resolved: Record<string, string> = {}
|
||||
const processedCanonicals = new Set<string>()
|
||||
|
||||
if (!connectorConfig) return resolved
|
||||
|
||||
for (const field of connectorConfig.configFields) {
|
||||
if (field.canonicalParamId) {
|
||||
if (processedCanonicals.has(field.canonicalParamId)) continue
|
||||
processedCanonicals.add(field.canonicalParamId)
|
||||
|
||||
const group = canonicalGroups.get(field.canonicalParamId)
|
||||
if (!group) continue
|
||||
|
||||
const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic'
|
||||
const activeField = group.find((f) => f.mode === activeMode) ?? group[0]
|
||||
const value = sourceConfig[activeField.id]
|
||||
if (value) resolved[field.canonicalParamId] = value
|
||||
} else {
|
||||
if (sourceConfig[field.id]) resolved[field.id] = sourceConfig[field.id]
|
||||
}
|
||||
}
|
||||
|
||||
return resolved
|
||||
}, [connectorConfig, canonicalGroups, canonicalModes, sourceConfig])
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!connectorConfig) return false
|
||||
if (isApiKeyMode) {
|
||||
@@ -99,20 +216,32 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
} else {
|
||||
if (!effectiveCredentialId) return false
|
||||
}
|
||||
return connectorConfig.configFields
|
||||
.filter((f) => f.required)
|
||||
.every((f) => sourceConfig[f.id]?.trim())
|
||||
}, [connectorConfig, isApiKeyMode, apiKeyValue, effectiveCredentialId, sourceConfig])
|
||||
|
||||
for (const field of connectorConfig.configFields) {
|
||||
if (!field.required) continue
|
||||
if (!isFieldVisible(field)) continue
|
||||
if (!sourceConfig[field.id]?.trim()) return false
|
||||
}
|
||||
return true
|
||||
}, [
|
||||
connectorConfig,
|
||||
isApiKeyMode,
|
||||
apiKeyValue,
|
||||
effectiveCredentialId,
|
||||
sourceConfig,
|
||||
isFieldVisible,
|
||||
])
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedType || !canSubmit) return
|
||||
|
||||
setError(null)
|
||||
|
||||
const resolvedConfig = resolveSourceConfig()
|
||||
const finalSourceConfig =
|
||||
disabledTagIds.size > 0
|
||||
? { ...sourceConfig, disabledTagIds: Array.from(disabledTagIds) }
|
||||
: sourceConfig
|
||||
? { ...resolvedConfig, disabledTagIds: Array.from(disabledTagIds) }
|
||||
: resolvedConfig
|
||||
|
||||
createConnector(
|
||||
{
|
||||
@@ -162,21 +291,19 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
setShowOAuthModal(true)
|
||||
}, [connectorConfig, connectorProviderId, workspaceId, session?.user?.name])
|
||||
|
||||
const connectorEntries = Object.entries(CONNECTOR_REGISTRY)
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
const term = searchTerm.toLowerCase().trim()
|
||||
if (!term) return connectorEntries
|
||||
return connectorEntries.filter(
|
||||
if (!term) return CONNECTOR_ENTRIES
|
||||
return CONNECTOR_ENTRIES.filter(
|
||||
([, config]) =>
|
||||
config.name.toLowerCase().includes(term) || config.description.toLowerCase().includes(term)
|
||||
)
|
||||
}, [connectorEntries, searchTerm])
|
||||
}, [searchTerm])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={(val) => !isCreating && onOpenChange(val)}>
|
||||
<ModalContent size='md'>
|
||||
<ModalContent size='md' className='h-[80vh] max-h-[560px]'>
|
||||
<ModalHeader>
|
||||
{step === 'configure' && (
|
||||
<Button
|
||||
@@ -205,7 +332,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<div className='max-h-[400px] min-h-0 overflow-y-auto'>
|
||||
<div className='min-h-[400px] overflow-y-auto'>
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
{filteredEntries.map(([type, config]) => (
|
||||
<ConnectorTypeCard
|
||||
@@ -216,7 +343,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
))}
|
||||
{filteredEntries.length === 0 && (
|
||||
<div className='py-[16px] text-center text-[14px] text-[var(--text-muted)]'>
|
||||
{connectorEntries.length === 0
|
||||
{CONNECTOR_ENTRIES.length === 0
|
||||
? 'No connectors available.'
|
||||
: `No sources found matching "${searchTerm}"`}
|
||||
</div>
|
||||
@@ -235,10 +362,12 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
: 'API Key'}
|
||||
</Label>
|
||||
<Input
|
||||
type='password'
|
||||
type={apiKeyFocused ? 'text' : 'password'}
|
||||
autoComplete='new-password'
|
||||
value={apiKeyValue}
|
||||
onChange={(e) => setApiKeyValue(e.target.value)}
|
||||
onFocus={() => setApiKeyFocused(true)}
|
||||
onBlur={() => setApiKeyFocused(false)}
|
||||
placeholder={
|
||||
connectorConfig.auth.mode === 'apiKey' && connectorConfig.auth.placeholder
|
||||
? connectorConfig.auth.placeholder
|
||||
@@ -287,41 +416,76 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
)}
|
||||
|
||||
{/* Config fields */}
|
||||
{connectorConfig.configFields.map((field) => (
|
||||
<div key={field.id} className='flex flex-col gap-[4px]'>
|
||||
<Label>
|
||||
{field.title}
|
||||
{field.required && (
|
||||
<span className='ml-[2px] text-[var(--text-error)]'>*</span>
|
||||
{connectorConfig.configFields.map((field) => {
|
||||
if (!isFieldVisible(field)) return null
|
||||
|
||||
const canonicalId = field.canonicalParamId
|
||||
const hasCanonicalPair =
|
||||
canonicalId && (canonicalGroups.get(canonicalId)?.length ?? 0) === 2
|
||||
|
||||
return (
|
||||
<div key={field.id} className='flex flex-col gap-[4px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label>
|
||||
{field.title}
|
||||
{field.required && (
|
||||
<span className='ml-[2px] text-[var(--text-error)]'>*</span>
|
||||
)}
|
||||
</Label>
|
||||
{hasCanonicalPair && canonicalId && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-[18px] w-[18px] items-center justify-center rounded-[3px] text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-secondary)]'
|
||||
onClick={() => toggleCanonicalMode(canonicalId)}
|
||||
>
|
||||
<ArrowLeftRight className='h-[12px] w-[12px]' />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{field.mode === 'basic'
|
||||
? 'Switch to manual input'
|
||||
: 'Switch to selector'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
{field.description && (
|
||||
<p className='text-[11px] text-[var(--text-muted)]'>{field.description}</p>
|
||||
)}
|
||||
</Label>
|
||||
{field.description && (
|
||||
<p className='text-[11px] text-[var(--text-muted)]'>{field.description}</p>
|
||||
)}
|
||||
{field.type === 'dropdown' && field.options ? (
|
||||
<Combobox
|
||||
size='sm'
|
||||
options={field.options.map((opt) => ({
|
||||
label: opt.label,
|
||||
value: opt.id,
|
||||
}))}
|
||||
value={sourceConfig[field.id] || undefined}
|
||||
onChange={(value) =>
|
||||
setSourceConfig((prev) => ({ ...prev, [field.id]: value }))
|
||||
}
|
||||
placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={sourceConfig[field.id] || ''}
|
||||
onChange={(e) =>
|
||||
setSourceConfig((prev) => ({ ...prev, [field.id]: e.target.value }))
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{field.type === 'selector' && field.selectorKey ? (
|
||||
<ConnectorSelectorField
|
||||
field={field as ConnectorConfigField & { selectorKey: SelectorKey }}
|
||||
value={sourceConfig[field.id] || ''}
|
||||
onChange={(value) => handleFieldChange(field.id, value)}
|
||||
credentialId={effectiveCredentialId}
|
||||
sourceConfig={sourceConfig}
|
||||
configFields={connectorConfig.configFields}
|
||||
canonicalModes={canonicalModes}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
) : field.type === 'dropdown' && field.options ? (
|
||||
<Combobox
|
||||
size='sm'
|
||||
options={field.options.map((opt) => ({
|
||||
label: opt.label,
|
||||
value: opt.id,
|
||||
}))}
|
||||
value={sourceConfig[field.id] || undefined}
|
||||
onChange={(value) => handleFieldChange(field.id, value)}
|
||||
placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={sourceConfig[field.id] || ''}
|
||||
onChange={(e) => handleFieldChange(field.id, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Tag definitions (opt-out) */}
|
||||
{connectorConfig.tagDefinitions && connectorConfig.tagDefinitions.length > 0 && (
|
||||
@@ -345,6 +509,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
>
|
||||
<Checkbox
|
||||
checked={!disabledTagIds.has(tagDef.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onCheckedChange={(checked) => {
|
||||
setDisabledTagIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -393,7 +558,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
|
||||
<Button variant='default' onClick={() => onOpenChange(false)} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' onClick={handleSubmit} disabled={!canSubmit || isCreating}>
|
||||
<Button variant='primary' onClick={handleSubmit} disabled={!canSubmit || isCreating}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn'
|
||||
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import type { ConnectorConfigField } from '@/connectors/types'
|
||||
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
|
||||
import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
|
||||
|
||||
interface ConnectorSelectorFieldProps {
|
||||
field: ConnectorConfigField & { selectorKey: SelectorKey }
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
credentialId: string | null
|
||||
sourceConfig: Record<string, string>
|
||||
configFields: ConnectorConfigField[]
|
||||
canonicalModes: Record<string, 'basic' | 'advanced'>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function ConnectorSelectorField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
credentialId,
|
||||
sourceConfig,
|
||||
configFields,
|
||||
canonicalModes,
|
||||
disabled,
|
||||
}: ConnectorSelectorFieldProps) {
|
||||
const context = useMemo<SelectorContext>(() => {
|
||||
const ctx: SelectorContext = {}
|
||||
if (credentialId) ctx.oauthCredential = credentialId
|
||||
|
||||
for (const depFieldId of getDependsOnFields(field.dependsOn)) {
|
||||
const depField = configFields.find((f) => f.id === depFieldId)
|
||||
const canonicalId = depField?.canonicalParamId ?? depFieldId
|
||||
const depValue = resolveDepValue(depFieldId, configFields, canonicalModes, sourceConfig)
|
||||
if (depValue && SELECTOR_CONTEXT_FIELDS.has(canonicalId as keyof SelectorContext)) {
|
||||
ctx[canonicalId as keyof SelectorContext] = depValue
|
||||
}
|
||||
}
|
||||
|
||||
return ctx
|
||||
}, [credentialId, field.dependsOn, sourceConfig, configFields, canonicalModes])
|
||||
|
||||
const depsResolved = useMemo(() => {
|
||||
if (!field.dependsOn) return true
|
||||
const deps = Array.isArray(field.dependsOn) ? field.dependsOn : (field.dependsOn.all ?? [])
|
||||
return deps.every((depId) =>
|
||||
Boolean(resolveDepValue(depId, configFields, canonicalModes, sourceConfig)?.trim())
|
||||
)
|
||||
}, [field.dependsOn, sourceConfig, configFields, canonicalModes])
|
||||
|
||||
const isEnabled = !disabled && !!credentialId && depsResolved
|
||||
const { data: options = [], isLoading } = useSelectorOptions(field.selectorKey, {
|
||||
context,
|
||||
enabled: isEnabled,
|
||||
})
|
||||
|
||||
const comboboxOptions = useMemo<ComboboxOption[]>(
|
||||
() => options.map((opt) => ({ label: opt.label, value: opt.id })),
|
||||
[options]
|
||||
)
|
||||
|
||||
if (isLoading && isEnabled) {
|
||||
return (
|
||||
<div className='flex items-center gap-2 rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] text-[var(--text-muted)] text-sm'>
|
||||
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
placeholder={
|
||||
!credentialId
|
||||
? 'Connect an account first'
|
||||
: !depsResolved
|
||||
? `Select ${getDependencyLabel(field, configFields)} first`
|
||||
: field.placeholder || `Select ${field.title.toLowerCase()}`
|
||||
}
|
||||
disabled={disabled || !credentialId || !depsResolved}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveDepValue(
|
||||
depFieldId: string,
|
||||
configFields: ConnectorConfigField[],
|
||||
canonicalModes: Record<string, 'basic' | 'advanced'>,
|
||||
sourceConfig: Record<string, string>
|
||||
): string {
|
||||
const depField = configFields.find((f) => f.id === depFieldId)
|
||||
if (!depField?.canonicalParamId) return sourceConfig[depFieldId] ?? ''
|
||||
|
||||
const activeMode = canonicalModes[depField.canonicalParamId] ?? 'basic'
|
||||
if (depField.mode === activeMode) return sourceConfig[depFieldId] ?? ''
|
||||
|
||||
const activeField = configFields.find(
|
||||
(f) => f.canonicalParamId === depField.canonicalParamId && f.mode === activeMode
|
||||
)
|
||||
return activeField ? (sourceConfig[activeField.id] ?? '') : (sourceConfig[depFieldId] ?? '')
|
||||
}
|
||||
|
||||
function getDependencyLabel(
|
||||
field: ConnectorConfigField,
|
||||
configFields: ConnectorConfigField[]
|
||||
): string {
|
||||
const deps = getDependsOnFields(field.dependsOn)
|
||||
const depField = deps.length > 0 ? configFields.find((f) => f.id === deps[0]) : undefined
|
||||
return depField?.title?.toLowerCase() ?? 'dependency'
|
||||
}
|
||||
@@ -347,7 +347,7 @@ export function AddDocumentsModal({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
type='button'
|
||||
onClick={handleUpload}
|
||||
disabled={files.length === 0 || isUploading}
|
||||
|
||||
@@ -387,7 +387,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={saveTagDefinition}
|
||||
className='flex-1'
|
||||
disabled={
|
||||
|
||||
@@ -130,8 +130,6 @@ export function ConnectorsSection({
|
||||
|
||||
return (
|
||||
<div className='mt-[16px]'>
|
||||
<h2 className='font-medium text-[14px] text-[var(--text-secondary)]'>Connected Sources</h2>
|
||||
|
||||
{error && (
|
||||
<p className='mt-[8px] text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>
|
||||
)}
|
||||
|
||||
@@ -161,7 +161,7 @@ export function EditConnectorModal({
|
||||
<Button variant='default' onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' onClick={handleSave} disabled={!hasChanges || isSaving}>
|
||||
<Button variant='primary' onClick={handleSave} disabled={!hasChanges || isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||
|
||||
@@ -124,7 +124,7 @@ export function RenameDocumentModal({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
type='submit'
|
||||
disabled={isSubmitting || !name?.trim() || name.trim() === initialName}
|
||||
>
|
||||
|
||||
@@ -522,7 +522,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
type='submit'
|
||||
disabled={isSubmitting || !nameValue?.trim()}
|
||||
>
|
||||
|
||||
@@ -159,7 +159,7 @@ export function EditKnowledgeBaseModal({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
type='submit'
|
||||
disabled={isSubmitting || !nameValue?.trim() || !isDirty}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { ToastProvider } from '@/components/emcn'
|
||||
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
|
||||
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
|
||||
@@ -9,7 +8,7 @@ import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/side
|
||||
|
||||
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<>
|
||||
<SettingsLoader />
|
||||
<ProviderModelsLoader />
|
||||
<GlobalCommandsProvider>
|
||||
@@ -26,6 +25,6 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
</WorkspacePermissionsProvider>
|
||||
</div>
|
||||
</GlobalCommandsProvider>
|
||||
</ToastProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -580,7 +580,7 @@ export const NotificationSettings = memo(function NotificationSettings({
|
||||
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => handleTest(subscription.id)}
|
||||
disabled={testNotification.isPending && testStatus?.id !== subscription.id}
|
||||
>
|
||||
@@ -1235,7 +1235,7 @@ export const NotificationSettings = memo(function NotificationSettings({
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleSave}
|
||||
disabled={createNotification.isPending || updateNotification.isPending}
|
||||
>
|
||||
@@ -1254,7 +1254,7 @@ export const NotificationSettings = memo(function NotificationSettings({
|
||||
resetForm()
|
||||
setShowForm(true)
|
||||
}}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
|
||||
@@ -541,7 +541,7 @@ export function ScheduleModal({ open, onOpenChange, workspaceId, schedule }: Sch
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
!isFormValid || createScheduleMutation.isPending || updateScheduleMutation.isPending
|
||||
|
||||
@@ -149,7 +149,7 @@ export function ApiKeys() {
|
||||
e.currentTarget.blur()
|
||||
setIsCreateDialogOpen(true)
|
||||
}}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
disabled={createButtonDisabled}
|
||||
>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
|
||||
@@ -190,7 +190,7 @@ export function CreateApiKeyModal({
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleCreateKey}
|
||||
disabled={
|
||||
!keyName.trim() ||
|
||||
|
||||
@@ -277,11 +277,7 @@ export function BYOK() {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant='primary'
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
onClick={() => openEditModal(provider.id)}
|
||||
>
|
||||
<Button variant='primary' onClick={() => openEditModal(provider.id)}>
|
||||
Add Key
|
||||
</Button>
|
||||
)}
|
||||
@@ -391,7 +387,7 @@ export function BYOK() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleSave}
|
||||
disabled={!apiKeyInput.trim() || upsertKey.isPending}
|
||||
>
|
||||
|
||||
@@ -200,7 +200,7 @@ export function Copilot() {
|
||||
setIsCreateDialogOpen(true)
|
||||
setCreateError(null)
|
||||
}}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
@@ -302,7 +302,7 @@ export function Copilot() {
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleCreateKey}
|
||||
disabled={!newKeyName.trim() || generateKey.isPending}
|
||||
>
|
||||
|
||||
@@ -628,7 +628,7 @@ export function CredentialSets() {
|
||||
/>
|
||||
</div>
|
||||
{canManageCredentialSets && (
|
||||
<Button variant='tertiary' onClick={() => setShowCreateModal(true)}>
|
||||
<Button variant='primary' onClick={() => setShowCreateModal(true)}>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Create
|
||||
</Button>
|
||||
@@ -671,7 +671,7 @@ export function CredentialSets() {
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => handleAcceptInvitation(invitation.token)}
|
||||
disabled={acceptInvitation.isPending}
|
||||
>
|
||||
@@ -843,7 +843,7 @@ export function CredentialSets() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleCreateCredentialSet}
|
||||
disabled={!newSetName.trim() || createCredentialSet.isPending}
|
||||
>
|
||||
|
||||
@@ -1311,7 +1311,7 @@ export function CredentialsManager() {
|
||||
</Button>
|
||||
{isSelectedAdmin && (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleSaveDetails}
|
||||
disabled={!isDetailsDirty || isSavingDetails}
|
||||
>
|
||||
@@ -1414,7 +1414,7 @@ export function CredentialsManager() {
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || !hasChanges || hasConflicts || hasInvalidKeys}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
className={`${hasConflicts || hasInvalidKeys ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
Save
|
||||
|
||||
@@ -101,7 +101,7 @@ export function CustomTools() {
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='tertiary'>
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='primary'>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Add
|
||||
</Button>
|
||||
|
||||
@@ -50,7 +50,7 @@ export function Debug() {
|
||||
disabled={importWorkflow.isPending}
|
||||
/>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleImport}
|
||||
disabled={importWorkflow.isPending || !workflowId.trim()}
|
||||
>
|
||||
|
||||
@@ -515,7 +515,7 @@ export function General() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleResetPasswordConfirm}
|
||||
disabled={resetPassword.isPending || resetPassword.isSuccess}
|
||||
>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function InboxEnableToggle() {
|
||||
<Button variant='default' onClick={() => setIsEnableOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' onClick={handleEnable} disabled={toggleInbox.isPending}>
|
||||
<Button variant='primary' onClick={handleEnable} disabled={toggleInbox.isPending}>
|
||||
{toggleInbox.isPending ? 'Enabling...' : 'Enable'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -282,7 +282,7 @@ export function InboxSettingsTab() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleAddSender}
|
||||
disabled={!newSenderEmail.trim() || addSender.isPending}
|
||||
>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function Inbox() {
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => router.push(`/workspace/${workspaceId}/settings/subscription`)}
|
||||
>
|
||||
Upgrade to Max
|
||||
|
||||
@@ -678,7 +678,7 @@ export function IntegrationsManager() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleCreateCredential}
|
||||
disabled={
|
||||
!createOAuthProviderId ||
|
||||
@@ -1027,7 +1027,7 @@ export function IntegrationsManager() {
|
||||
</Button>
|
||||
{isSelectedAdmin && (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleSaveDetails}
|
||||
disabled={!isDetailsDirty || isSavingDetails}
|
||||
>
|
||||
@@ -1066,7 +1066,7 @@ export function IntegrationsManager() {
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={credentialsLoading}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Connect
|
||||
|
||||
@@ -719,12 +719,12 @@ export function McpServerFormModal({
|
||||
<Button
|
||||
onClick={handleSubmitJson}
|
||||
disabled={isSubmitting || !jsonInput.trim()}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : submitLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSubmitForm} disabled={isSubmitDisabled} variant='tertiary'>
|
||||
<Button onClick={handleSubmitForm} disabled={isSubmitDisabled} variant='primary'>
|
||||
{isSubmitting ? (mode === 'add' ? 'Adding...' : 'Saving...') : submitLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -637,11 +637,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
variant='tertiary'
|
||||
disabled={serversLoading}
|
||||
>
|
||||
<Button onClick={() => setShowAddModal(true)} variant='primary' disabled={serversLoading}>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Add
|
||||
</Button>
|
||||
|
||||
@@ -303,7 +303,7 @@ export function RecentlyDeleted() {
|
||||
<div className='flex shrink-0 items-center gap-[8px]'>
|
||||
<span className='text-[13px] text-[var(--text-tertiary)]'>Restored</span>
|
||||
<Button
|
||||
variant='default'
|
||||
variant='primary'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
router.push(
|
||||
@@ -316,7 +316,7 @@ export function RecentlyDeleted() {
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant='default'
|
||||
variant='primary'
|
||||
size='sm'
|
||||
disabled={isRestoring}
|
||||
onClick={() => handleRestore(resource)}
|
||||
|
||||
@@ -210,7 +210,7 @@ export function SkillModal({
|
||||
<Button variant='default' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
<Button variant='primary' onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
{saving ? 'Saving...' : initialValues ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -95,7 +95,7 @@ export function Skills() {
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='tertiary'>
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='primary'>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Add
|
||||
</Button>
|
||||
|
||||
@@ -160,7 +160,7 @@ export function CreditBalance({
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handlePurchase}
|
||||
disabled={purchaseCredits.isPending || !amount}
|
||||
>
|
||||
|
||||
@@ -245,7 +245,7 @@ function CreditPlanCard({
|
||||
{isCancelledAtPeriodEnd ? 'Restore Subscription' : 'Manage plan'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onButtonClick} className='w-full' variant='tertiary'>
|
||||
<Button onClick={onButtonClick} className='w-full' variant='primary'>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
@@ -1134,7 +1134,7 @@ function TeamPlanModal({ open, onOpenChange, isAnnual, onConfirm }: TeamPlanModa
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => onConfirm(selectedTier, selectedSeats)}
|
||||
disabled={selectedSeats < 1}
|
||||
>
|
||||
@@ -1291,7 +1291,7 @@ function ManagePlanModal({
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
className='ml-[12px] shrink-0'
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
@@ -1312,7 +1312,7 @@ function ManagePlanModal({
|
||||
<Button variant='default' onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant='tertiary' onClick={onRestore}>
|
||||
<Button variant='primary' onClick={onRestore}>
|
||||
Restore Subscription
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -239,7 +239,7 @@ export function MemberInvitationCard({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => onInviteMember()}
|
||||
disabled={!hasValidEmails || isInviting || !hasAvailableSeats}
|
||||
>
|
||||
|
||||
@@ -111,7 +111,7 @@ export function NoOrganizationView({
|
||||
)}
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={onCreateOrganization}
|
||||
disabled={!orgName || !orgSlug || isCreatingOrg}
|
||||
>
|
||||
@@ -193,7 +193,7 @@ export function NoOrganizationView({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={onCreateOrganization}
|
||||
disabled={isCreatingOrg || !orgName.trim()}
|
||||
>
|
||||
@@ -217,7 +217,7 @@ export function NoOrganizationView({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant='tertiary' onClick={() => navigateToSettings({ section: 'subscription' })}>
|
||||
<Button variant='primary' onClick={() => navigateToSettings({ section: 'subscription' })}>
|
||||
Upgrade to Team Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@ export function TeamSeatsOverview({
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => {
|
||||
onConfirmTeamUpgrade(2)
|
||||
}}
|
||||
|
||||
@@ -135,7 +135,7 @@ export function TeamSeats({
|
||||
<Tooltip.Trigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => onConfirm(selectedSeats)}
|
||||
disabled={
|
||||
isLoading ||
|
||||
|
||||
@@ -505,7 +505,7 @@ export function TemplateProfile() {
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={saveStatus === 'saving' || !isFormValid}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
>
|
||||
{saveStatus === 'saving' ? 'Saving...' : saveStatus === 'saved' ? 'Saved' : 'Save'}
|
||||
</Button>
|
||||
|
||||
@@ -349,7 +349,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='inline-flex'>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => setShowAddWorkflow(true)}
|
||||
disabled
|
||||
>
|
||||
@@ -364,7 +364,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
</Tooltip.Root>
|
||||
) : (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => setShowAddWorkflow(true)}
|
||||
disabled={!canAddWorkflow}
|
||||
>
|
||||
@@ -480,7 +480,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
workflows via the MCP block.
|
||||
</p>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
className='self-start'
|
||||
disabled={addToWorkspaceMutation.isPending || addedToWorkspace}
|
||||
onClick={async () => {
|
||||
@@ -739,7 +739,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={async () => {
|
||||
if (!toolToView) return
|
||||
try {
|
||||
@@ -859,7 +859,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleAddWorkflow}
|
||||
disabled={!selectedWorkflowId || addToolMutation.isPending}
|
||||
>
|
||||
@@ -920,7 +920,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleSaveServerEdit}
|
||||
disabled={
|
||||
!editServerName.trim() ||
|
||||
@@ -1059,7 +1059,7 @@ export function WorkflowMcpServers() {
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddModal(true)} disabled={isLoading} variant='tertiary'>
|
||||
<Button onClick={() => setShowAddModal(true)} disabled={isLoading} variant='primary'>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Add
|
||||
</Button>
|
||||
@@ -1203,7 +1203,7 @@ export function WorkflowMcpServers() {
|
||||
<Button
|
||||
onClick={handleCreateServer}
|
||||
disabled={!isFormValid || createServerMutation.isPending}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
>
|
||||
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
|
||||
</Button>
|
||||
|
||||
@@ -221,7 +221,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleFormSubmit}
|
||||
disabled={isSubmitting}
|
||||
className='min-w-[120px]'
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { memo, useCallback, useEffect, useRef } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toast, useToast } from '@/components/emcn'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import {
|
||||
type Notification,
|
||||
type NotificationAction,
|
||||
@@ -12,6 +14,13 @@ import {
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('Notifications')
|
||||
const MAX_VISIBLE_NOTIFICATIONS = 4
|
||||
const STACK_OFFSET_PX = 3
|
||||
const AUTO_DISMISS_MS = 10000
|
||||
const EXIT_ANIMATION_MS = 200
|
||||
|
||||
const RING_RADIUS = 5.5
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
|
||||
|
||||
const ACTION_LABELS: Record<NotificationAction['type'], string> = {
|
||||
copilot: 'Fix in Copilot',
|
||||
@@ -19,99 +28,120 @@ const ACTION_LABELS: Record<NotificationAction['type'], string> = {
|
||||
'unlock-workflow': 'Unlock Workflow',
|
||||
} as const
|
||||
|
||||
function executeNotificationAction(action: NotificationAction) {
|
||||
switch (action.type) {
|
||||
case 'copilot':
|
||||
openCopilotWithMessage(action.message)
|
||||
break
|
||||
case 'refresh':
|
||||
window.location.reload()
|
||||
break
|
||||
case 'unlock-workflow':
|
||||
window.dispatchEvent(new CustomEvent('unlock-workflow'))
|
||||
break
|
||||
default:
|
||||
logger.warn('Unknown action type', { actionType: action.type })
|
||||
}
|
||||
function isAutoDismissable(n: Notification): boolean {
|
||||
return n.level === 'error' && !!n.workflowId
|
||||
}
|
||||
|
||||
function notificationToToast(n: Notification, removeNotification: (id: string) => void) {
|
||||
const toastAction = n.action
|
||||
? {
|
||||
label: ACTION_LABELS[n.action.type] ?? 'Take action',
|
||||
onClick: () => {
|
||||
executeNotificationAction(n.action!)
|
||||
removeNotification(n.id)
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
|
||||
return {
|
||||
message: n.message,
|
||||
variant: n.level === 'error' ? ('error' as const) : ('default' as const),
|
||||
action: toastAction,
|
||||
duration: n.level === 'error' && n.workflowId ? 10_000 : 0,
|
||||
}
|
||||
function CountdownRing({ onPause }: { onPause: () => void }) {
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={onPause}
|
||||
aria-label='Keep notifications visible'
|
||||
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<svg
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 16 16'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
|
||||
>
|
||||
<circle cx='8' cy='8' r={RING_RADIUS} stroke='var(--border)' strokeWidth='1.5' />
|
||||
<circle
|
||||
cx='8'
|
||||
cy='8'
|
||||
r={RING_RADIUS}
|
||||
stroke='var(--text-icon)'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeDasharray={RING_CIRCUMFERENCE}
|
||||
style={{
|
||||
animation: `notification-countdown ${AUTO_DISMISS_MS}ms linear forwards`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Keep visible</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless bridge that syncs the notification Zustand store into the toast system.
|
||||
* Notifications display component.
|
||||
* Positioned in the bottom-right workspace area, reactive to panel width and terminal height.
|
||||
* Shows both global notifications and workflow-specific notifications.
|
||||
*
|
||||
* Watches for new notifications scoped to the active workflow and shows them as toasts.
|
||||
* When a toast is dismissed, the corresponding notification is removed from the store.
|
||||
* 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() {
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
|
||||
const allNotifications = useNotificationStore((state) => state.notifications)
|
||||
const removeNotification = useNotificationStore((state) => state.removeNotification)
|
||||
const clearNotifications = useNotificationStore((state) => state.clearNotifications)
|
||||
const { dismissAll } = useToast()
|
||||
|
||||
const shownIdsRef = useRef(new Set<string>())
|
||||
const visibleNotifications = useMemo(() => {
|
||||
if (!activeWorkflowId) return []
|
||||
return allNotifications
|
||||
.filter((n) => !n.workflowId || n.workflowId === activeWorkflowId)
|
||||
.slice(0, MAX_VISIBLE_NOTIFICATIONS)
|
||||
}, [allNotifications, activeWorkflowId])
|
||||
|
||||
const showNotification = useCallback(
|
||||
(n: Notification) => {
|
||||
if (shownIdsRef.current.has(n.id)) return
|
||||
shownIdsRef.current.add(n.id)
|
||||
/**
|
||||
* Executes a notification action and handles side effects.
|
||||
*
|
||||
* @param notificationId - The ID of the notification whose action is executed.
|
||||
* @param action - The action configuration to execute.
|
||||
*/
|
||||
const executeAction = useCallback(
|
||||
(notificationId: string, action: NotificationAction) => {
|
||||
try {
|
||||
logger.info('Executing notification action', {
|
||||
notificationId,
|
||||
actionType: action.type,
|
||||
messageLength: action.message.length,
|
||||
})
|
||||
|
||||
const input = notificationToToast(n, removeNotification)
|
||||
toast(input)
|
||||
switch (action.type) {
|
||||
case 'copilot':
|
||||
openCopilotWithMessage(action.message)
|
||||
break
|
||||
case 'refresh':
|
||||
window.location.reload()
|
||||
break
|
||||
case 'unlock-workflow':
|
||||
window.dispatchEvent(new CustomEvent('unlock-workflow'))
|
||||
break
|
||||
default:
|
||||
logger.warn('Unknown action type', { notificationId, actionType: action.type })
|
||||
}
|
||||
|
||||
logger.info('Notification shown as toast', {
|
||||
id: n.id,
|
||||
level: n.level,
|
||||
workflowId: n.workflowId,
|
||||
})
|
||||
removeNotification(notificationId)
|
||||
} catch (error) {
|
||||
logger.error('Failed to execute notification action', {
|
||||
notificationId,
|
||||
actionType: action.type,
|
||||
error,
|
||||
})
|
||||
}
|
||||
},
|
||||
[removeNotification]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
const visible = allNotifications.filter(
|
||||
(n) => !n.workflowId || n.workflowId === activeWorkflowId
|
||||
)
|
||||
|
||||
for (const n of visible) {
|
||||
showNotification(n)
|
||||
}
|
||||
|
||||
const currentIds = new Set(allNotifications.map((n) => n.id))
|
||||
for (const id of shownIdsRef.current) {
|
||||
if (!currentIds.has(id)) {
|
||||
shownIdsRef.current.delete(id)
|
||||
}
|
||||
}
|
||||
}, [allNotifications, activeWorkflowId, showNotification])
|
||||
|
||||
useRegisterGlobalCommands(() =>
|
||||
createCommands([
|
||||
{
|
||||
id: 'clear-notifications',
|
||||
handler: () => {
|
||||
clearNotifications(activeWorkflowId ?? undefined)
|
||||
dismissAll()
|
||||
},
|
||||
overrides: {
|
||||
allowInEditable: false,
|
||||
@@ -120,5 +150,144 @@ export const Notifications = memo(function Notifications() {
|
||||
])
|
||||
)
|
||||
|
||||
return null
|
||||
const preventZoomRef = usePreventZoom()
|
||||
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const isPausedRef = useRef(false)
|
||||
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
|
||||
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
|
||||
|
||||
const pauseAll = useCallback(() => {
|
||||
setIsPaused(true)
|
||||
isPausedRef.current = true
|
||||
setExitingIds(new Set())
|
||||
for (const timer of timersRef.current.values()) clearTimeout(timer)
|
||||
timersRef.current.clear()
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Manages per-notification dismiss timers.
|
||||
* Resets pause state when the notification stack empties so new arrivals get fresh timers.
|
||||
*/
|
||||
useEffect(() => {
|
||||
isPausedRef.current = isPaused
|
||||
}, [isPaused])
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleNotifications.length === 0) {
|
||||
if (isPaused) setIsPaused(false)
|
||||
for (const timer of timersRef.current.values()) clearTimeout(timer)
|
||||
timersRef.current.clear()
|
||||
return
|
||||
}
|
||||
if (isPaused) return
|
||||
|
||||
const timers = timersRef.current
|
||||
const activeIds = new Set<string>()
|
||||
|
||||
for (const n of visibleNotifications) {
|
||||
if (!isAutoDismissable(n) || timers.has(n.id)) continue
|
||||
activeIds.add(n.id)
|
||||
|
||||
timers.set(
|
||||
n.id,
|
||||
setTimeout(() => {
|
||||
timers.delete(n.id)
|
||||
setExitingIds((prev) => new Set(prev).add(n.id))
|
||||
setTimeout(() => {
|
||||
if (isPausedRef.current) return
|
||||
removeNotification(n.id)
|
||||
setExitingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(n.id)
|
||||
return next
|
||||
})
|
||||
}, EXIT_ANIMATION_MS)
|
||||
}, AUTO_DISMISS_MS)
|
||||
)
|
||||
}
|
||||
|
||||
for (const [id, timer] of timers) {
|
||||
if (!activeIds.has(id) && !visibleNotifications.some((n) => n.id === id)) {
|
||||
clearTimeout(timer)
|
||||
timers.delete(id)
|
||||
}
|
||||
}
|
||||
}, [visibleNotifications, removeNotification, isPaused])
|
||||
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current
|
||||
return () => {
|
||||
for (const timer of timers.values()) clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (visibleNotifications.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={preventZoomRef} className='absolute right-[16px] bottom-[16px] z-30 grid'>
|
||||
{[...visibleNotifications].reverse().map((notification, index, stacked) => {
|
||||
const depth = stacked.length - index - 1
|
||||
const xOffset = depth * STACK_OFFSET_PX
|
||||
const hasAction = Boolean(notification.action)
|
||||
const showCountdown = !isPaused && isAutoDismissable(notification)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={notification.id}
|
||||
style={
|
||||
{
|
||||
'--stack-offset': `${xOffset}px`,
|
||||
animation: exitingIds.has(notification.id)
|
||||
? `notification-exit ${EXIT_ANIMATION_MS}ms ease-in forwards`
|
||||
: 'notification-enter 200ms ease-out forwards',
|
||||
gridArea: '1 / 1',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className='w-[240px] self-end overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)] shadow-sm'
|
||||
>
|
||||
<div className='flex flex-col gap-[8px] p-[8px]'>
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
<div className='line-clamp-2 min-w-0 flex-1 font-medium text-[12px] text-[var(--text-body)]'>
|
||||
{notification.level === 'error' && (
|
||||
<span className='mr-[8px] mb-[2px] inline-block h-[8px] w-[8px] rounded-[2px] bg-[var(--text-error)] align-middle' />
|
||||
)}
|
||||
{notification.message}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-start gap-[2px]'>
|
||||
{showCountdown && <CountdownRing onPause={pauseAll} />}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeNotification(notification.id)}
|
||||
aria-label='Dismiss notification'
|
||||
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<X className='h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<Tooltip.Shortcut keys='⌘E'>Clear all</Tooltip.Shortcut>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
{hasAction && (
|
||||
<Button
|
||||
variant='active'
|
||||
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'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -65,7 +65,7 @@ export function CheckpointConfirmation({
|
||||
{!isRestoreVariant && onContinue && (
|
||||
<Button
|
||||
onClick={onContinue}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
size='sm'
|
||||
className='flex-1'
|
||||
disabled={isProcessing}
|
||||
|
||||
@@ -1390,7 +1390,7 @@ function RunSkipButtons({
|
||||
// Standardized buttons for all interrupt tools: Allow, Always Allow, Skip
|
||||
return (
|
||||
<div className='mt-[10px] flex gap-[6px]'>
|
||||
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
||||
<Button onClick={onRun} disabled={isProcessing} variant='primary'>
|
||||
{isProcessing ? 'Allowing...' : 'Allow'}
|
||||
</Button>
|
||||
{showAlwaysAllow && (
|
||||
@@ -2130,7 +2130,7 @@ export function ToolCall({
|
||||
onStateChange?.('background')
|
||||
await sendToolDecision(toolCall.id, 'background')
|
||||
}}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
title='Move to Background'
|
||||
>
|
||||
Move to Background
|
||||
@@ -2144,7 +2144,7 @@ export function ToolCall({
|
||||
onStateChange?.('background')
|
||||
await sendToolDecision(toolCall.id, 'background')
|
||||
}}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
title='Wake'
|
||||
>
|
||||
Wake
|
||||
|
||||
@@ -48,7 +48,8 @@ import { ChatDeploy, type ExistingChat } from './components/chat/chat'
|
||||
import { ApiInfoModal } from './components/general/components/api-info-modal'
|
||||
import { GeneralDeploy } from './components/general/general'
|
||||
import { McpDeploy } from './components/mcp/mcp'
|
||||
import { TemplateDeploy } from './components/template/template'
|
||||
|
||||
// import { TemplateDeploy } from './components/template/template'
|
||||
|
||||
const logger = createLogger('DeployModal')
|
||||
|
||||
@@ -497,9 +498,9 @@ export function DeployModal({
|
||||
<ModalTabsTrigger value='chat'>Chat</ModalTabsTrigger>
|
||||
)}
|
||||
{/* <ModalTabsTrigger value='form'>Form</ModalTabsTrigger> */}
|
||||
{!permissionConfig.hideDeployTemplate && (
|
||||
{/* {!permissionConfig.hideDeployTemplate && (
|
||||
<ModalTabsTrigger value='template'>Template</ModalTabsTrigger>
|
||||
)}
|
||||
)} */}
|
||||
</ModalTabsList>
|
||||
|
||||
<ModalBody className='min-h-0 flex-1'>
|
||||
@@ -563,7 +564,7 @@ export function DeployModal({
|
||||
/>
|
||||
</ModalTabsContent>
|
||||
|
||||
<ModalTabsContent value='template'>
|
||||
{/* <ModalTabsContent value='template'>
|
||||
{workflowId && (
|
||||
<TemplateDeploy
|
||||
workflowId={workflowId}
|
||||
@@ -572,7 +573,7 @@ export function DeployModal({
|
||||
onSubmittingChange={setTemplateSubmitting}
|
||||
/>
|
||||
)}
|
||||
</ModalTabsContent>
|
||||
</ModalTabsContent> */}
|
||||
|
||||
{/* <ModalTabsContent value='form'>
|
||||
{workflowId && (
|
||||
@@ -705,7 +706,7 @@ export function DeployModal({
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)}
|
||||
{activeTab === 'template' && (
|
||||
{/* {activeTab === 'template' && (
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
{hasExistingTemplate && templateStatus ? (
|
||||
<TemplateStatusBadge
|
||||
@@ -743,7 +744,7 @@ export function DeployModal({
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)}
|
||||
)} */}
|
||||
{/* {activeTab === 'form' && (
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
<div />
|
||||
@@ -1006,12 +1007,7 @@ function GeneralFooter({
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
<StatusBadge isWarning={needsRedeployment} />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={onUndeploy}
|
||||
disabled={isUndeploying || isSubmitting}
|
||||
className='px-[7px] py-[5px]'
|
||||
>
|
||||
<Button variant='default' onClick={onUndeploy} disabled={isUndeploying || isSubmitting}>
|
||||
{isUndeploying ? 'Undeploying...' : 'Undeploy'}
|
||||
</Button>
|
||||
{needsRedeployment && (
|
||||
|
||||
@@ -180,7 +180,7 @@ export function OAuthRequiredModal({
|
||||
<Button variant='default' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='tertiary' type='button' onClick={handleConnectDirectly}>
|
||||
<Button variant='primary' type='button' onClick={handleConnectDirectly}>
|
||||
Connect
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -863,7 +863,7 @@ try {
|
||||
placeholder='Generate...'
|
||||
/>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
disabled={!schemaPromptInput.trim() || schemaGeneration.isStreaming}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -955,7 +955,7 @@ try {
|
||||
placeholder='Generate...'
|
||||
/>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
disabled={!codePromptInput.trim() || codeGeneration.isStreaming}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -1135,7 +1135,7 @@ try {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => setActiveSection('code')}
|
||||
disabled={!isSchemaValid || !!schemaError}
|
||||
>
|
||||
@@ -1161,7 +1161,7 @@ try {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleSave}
|
||||
disabled={!isSchemaValid || !!schemaError || !hasChanges}
|
||||
>
|
||||
|
||||
@@ -129,7 +129,7 @@ export function ParameterWithLabel({
|
||||
placeholder='Generate with AI...'
|
||||
/>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
disabled={!searchQuery.trim() || isStreaming}
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -331,7 +331,7 @@ const renderLabel = (
|
||||
placeholder='Generate with AI...'
|
||||
/>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
disabled={!wandState.searchQuery.trim() || wandState.isStreaming}
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -27,14 +27,9 @@ import {
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { FilterPopover } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover'
|
||||
import { OutputContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu'
|
||||
import { StructuredOutput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output'
|
||||
import { ToggleButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button'
|
||||
import type {
|
||||
BlockInfo,
|
||||
TerminalFilters,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||
import type { ConsoleEntry } from '@/stores/terminal'
|
||||
@@ -100,16 +95,11 @@ export interface OutputPanelProps {
|
||||
handleCopy: () => void
|
||||
filteredEntries: ConsoleEntry[]
|
||||
handleExportConsole: (e: React.MouseEvent) => void
|
||||
hasActiveFilters: boolean
|
||||
handleClearConsole: (e: React.MouseEvent) => void
|
||||
shouldShowCodeDisplay: boolean
|
||||
outputDataStringified: string
|
||||
outputData: unknown
|
||||
handleClearConsoleFromMenu: () => void
|
||||
filters: TerminalFilters
|
||||
toggleBlock: (blockId: string) => void
|
||||
toggleStatus: (status: 'error' | 'info') => void
|
||||
uniqueBlocks: BlockInfo[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,16 +123,11 @@ export const OutputPanel = React.memo(function OutputPanel({
|
||||
handleCopy,
|
||||
filteredEntries,
|
||||
handleExportConsole,
|
||||
hasActiveFilters,
|
||||
handleClearConsole,
|
||||
shouldShowCodeDisplay,
|
||||
outputDataStringified,
|
||||
outputData,
|
||||
handleClearConsoleFromMenu,
|
||||
filters,
|
||||
toggleBlock,
|
||||
toggleStatus,
|
||||
uniqueBlocks,
|
||||
}: OutputPanelProps) {
|
||||
// Access store-backed settings directly to reduce prop drilling
|
||||
const outputPanelWidth = useTerminalStore((state) => state.outputPanelWidth)
|
||||
@@ -154,7 +139,6 @@ export const OutputPanel = React.memo(function OutputPanel({
|
||||
const setStructuredView = useTerminalStore((state) => state.setStructuredView)
|
||||
|
||||
const outputContentRef = useRef<HTMLDivElement>(null)
|
||||
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||
const [outputOptionsOpen, setOutputOptionsOpen] = useState(false)
|
||||
const {
|
||||
isSearchActive: isOutputSearchActive,
|
||||
@@ -339,19 +323,6 @@ export const OutputPanel = React.memo(function OutputPanel({
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
{/* Unified filter popover */}
|
||||
{filteredEntries.length > 0 && (
|
||||
<FilterPopover
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
filters={filters}
|
||||
toggleStatus={toggleStatus}
|
||||
toggleBlock={toggleBlock}
|
||||
uniqueBlocks={uniqueBlocks}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isOutputSearchActive ? (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
|
||||
@@ -28,7 +28,6 @@ import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import {
|
||||
FilterPopover,
|
||||
LogRowContextMenu,
|
||||
OutputPanel,
|
||||
StatusDisplay,
|
||||
@@ -603,7 +602,6 @@ export const Terminal = memo(function Terminal() {
|
||||
const [showCopySuccess, setShowCopySuccess] = useState(false)
|
||||
const [showInput, setShowInput] = useState(false)
|
||||
const [autoSelectEnabled, setAutoSelectEnabled] = useState(true)
|
||||
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||
const [mainOptionsOpen, setMainOptionsOpen] = useState(false)
|
||||
|
||||
const [isTrainingEnvEnabled, setIsTrainingEnvEnabled] = useState(false)
|
||||
@@ -676,23 +674,6 @@ export const Terminal = memo(function Terminal() {
|
||||
return result
|
||||
}, [executionGroups])
|
||||
|
||||
/**
|
||||
* Get unique blocks (by ID) from all workflow entries
|
||||
*/
|
||||
const uniqueBlocks = useMemo(() => {
|
||||
const blocksMap = new Map<string, { blockId: string; blockName: string; blockType: string }>()
|
||||
allWorkflowEntries.forEach((entry) => {
|
||||
if (!blocksMap.has(entry.blockId)) {
|
||||
blocksMap.set(entry.blockId, {
|
||||
blockId: entry.blockId,
|
||||
blockName: entry.blockName,
|
||||
blockType: entry.blockType,
|
||||
})
|
||||
}
|
||||
})
|
||||
return Array.from(blocksMap.values()).sort((a, b) => a.blockName.localeCompare(b.blockName))
|
||||
}, [allWorkflowEntries])
|
||||
|
||||
/**
|
||||
* Check if input data exists for selected entry
|
||||
*/
|
||||
@@ -1289,22 +1270,9 @@ export const Terminal = memo(function Terminal() {
|
||||
{/* Left side - Logs label */}
|
||||
<span className={TERMINAL_CONFIG.HEADER_TEXT_CLASS}>Logs</span>
|
||||
|
||||
{/* Right side - Filters and icons */}
|
||||
{/* Right side - Icons and options */}
|
||||
{!selectedEntry && (
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{/* Unified filter popover */}
|
||||
{allWorkflowEntries.length > 0 && (
|
||||
<FilterPopover
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
filters={filters}
|
||||
toggleStatus={toggleStatus}
|
||||
toggleBlock={toggleBlock}
|
||||
uniqueBlocks={uniqueBlocks}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sort toggle */}
|
||||
{allWorkflowEntries.length > 0 && (
|
||||
<Tooltip.Root>
|
||||
@@ -1497,16 +1465,11 @@ export const Terminal = memo(function Terminal() {
|
||||
handleCopy={handleCopy}
|
||||
filteredEntries={filteredEntries}
|
||||
handleExportConsole={handleExportConsole}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
handleClearConsole={handleClearConsole}
|
||||
shouldShowCodeDisplay={shouldShowCodeDisplay}
|
||||
outputDataStringified={outputDataStringified}
|
||||
outputData={outputData}
|
||||
handleClearConsoleFromMenu={handleClearConsoleFromMenu}
|
||||
filters={filters}
|
||||
toggleBlock={toggleBlock}
|
||||
toggleStatus={toggleStatus}
|
||||
uniqueBlocks={uniqueBlocks}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -372,7 +372,7 @@ export function TrainingModal() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => {
|
||||
useCopilotTrainingStore.getState().stopTraining()
|
||||
setLocalPrompt('')
|
||||
@@ -439,7 +439,7 @@ export function TrainingModal() {
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
disabled={!localTitle.trim() || !localPrompt.trim()}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
className='w-full'
|
||||
>
|
||||
Start Training Session
|
||||
@@ -470,7 +470,7 @@ export function TrainingModal() {
|
||||
<div className='flex gap-[8px]'>
|
||||
{selectedDatasets.size > 0 && (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={handleSendSelected}
|
||||
disabled={sendingSelected}
|
||||
>
|
||||
@@ -755,7 +755,7 @@ export function TrainingModal() {
|
||||
sendingLiveWorkflow ||
|
||||
currentWorkflow.getBlockCount() === 0
|
||||
}
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
className={cn(
|
||||
'w-full',
|
||||
liveWorkflowSent && '!bg-green-600 !text-white hover:!bg-green-700',
|
||||
|
||||
@@ -539,7 +539,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
|
||||
<Button variant='default' onClick={handleClose} type='button' disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' variant='tertiary' disabled={isSubmitting || isProcessing}>
|
||||
<Button type='submit' variant='primary' disabled={isSubmitting || isProcessing}>
|
||||
{isSubmitting
|
||||
? 'Submitting...'
|
||||
: submitStatus === 'error'
|
||||
|
||||
@@ -209,7 +209,7 @@ function ColorPickerSubmenu({
|
||||
className='h-[20px] min-w-0 flex-1 rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] font-medium text-[11px] text-[var(--text-primary)] uppercase transition-colors focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
disabled={!canSubmitHex}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -77,7 +77,7 @@ export function CreateWorkspaceModal({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={!name.trim() || isCreating}
|
||||
>
|
||||
|
||||
@@ -575,7 +575,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
disabled={isSaving || isSubmitting}
|
||||
onClick={handleSaveChanges}
|
||||
tabIndex={hasPendingChanges && userPerms.canAdmin ? 0 : -1}
|
||||
@@ -586,7 +586,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
variant='tertiary'
|
||||
variant='primary'
|
||||
onClick={() => formRef.current?.requestSubmit()}
|
||||
disabled={
|
||||
!userPerms.canAdmin || isSubmitting || isSaving || !workspaceId || !hasNewInvites
|
||||
|
||||
@@ -365,6 +365,7 @@ export function WorkspaceHeader({
|
||||
align='start'
|
||||
side={isCollapsed ? 'right' : 'bottom'}
|
||||
sideOffset={isCollapsed ? 16 : 8}
|
||||
className='flex max-h-none flex-col overflow-hidden'
|
||||
style={
|
||||
isCollapsed
|
||||
? {
|
||||
@@ -385,7 +386,7 @@ export function WorkspaceHeader({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex items-center gap-[8px] px-[2px] py-[4px]'>
|
||||
<div className='flex items-center gap-[8px] px-[2px] py-[2px]'>
|
||||
<div
|
||||
className='flex h-[32px] w-[32px] flex-shrink-0 items-center justify-center rounded-[6px] font-medium text-[12px] text-white'
|
||||
style={{
|
||||
@@ -404,7 +405,7 @@ export function WorkspaceHeader({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenuGroup className='mt-[4px]'>
|
||||
<DropdownMenuGroup className='mt-[4px] min-h-0 flex-1'>
|
||||
<div className='flex max-h-[130px] flex-col gap-[2px] overflow-y-auto'>
|
||||
{workspaces.map((workspace) => (
|
||||
<div key={workspace.id}>
|
||||
@@ -492,7 +493,9 @@ export function WorkspaceHeader({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<div className='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'
|
||||
@@ -506,7 +509,7 @@ export function WorkspaceHeader({
|
||||
<Plus className='h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
|
||||
Create new workspace
|
||||
</button>
|
||||
</DropdownMenuGroup>
|
||||
</div>
|
||||
|
||||
{!isInvitationsDisabled && (
|
||||
<>
|
||||
|
||||
@@ -1056,10 +1056,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div
|
||||
className={cn(
|
||||
'font-base text-[var(--text-icon)] text-small',
|
||||
isCollapsed && 'opacity-0'
|
||||
)}
|
||||
className={`font-base text-[var(--text-icon)] text-small${isCollapsed ? ' opacity-0' : ''}`}
|
||||
>
|
||||
All tasks
|
||||
</div>
|
||||
|
||||
@@ -18,5 +18,9 @@ export default function WorkspaceRootLayout({ children }: WorkspaceRootLayoutPro
|
||||
}
|
||||
: undefined
|
||||
|
||||
return <SocketProvider user={user}>{children}</SocketProvider>
|
||||
return (
|
||||
<SocketProvider user={user}>
|
||||
<div className='tracking-[0.02em]'>{children}</div>
|
||||
</SocketProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export const colors = {
|
||||
/** Brand primary - purple */
|
||||
brandPrimary: '#6f3dfa',
|
||||
/** Brand tertiary - green (matches Run/Deploy buttons) */
|
||||
brandTertiary: '#32bd7e',
|
||||
brandTertiary: '#33C482',
|
||||
/** Border/divider color */
|
||||
divider: '#ededed',
|
||||
/** Footer background */
|
||||
|
||||
@@ -104,7 +104,7 @@ const buttonGroupItemVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
active: {
|
||||
true: 'bg-[var(--brand-tertiary-2)] text-[var(--text-inverse)] border-[var(--brand-tertiary-2)] hover:brightness-106',
|
||||
true: 'bg-[#1D1D1D] text-[var(--text-inverse)] border-[#1D1D1D] hover:bg-[#2A2A2A] hover:border-[#2A2A2A] dark:bg-white dark:border-white dark:hover:bg-[#E0E0E0] dark:hover:border-[#E0E0E0]',
|
||||
false:
|
||||
'bg-[var(--surface-4)] text-[var(--text-secondary)] border-[var(--border)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-6)] hover:border-[var(--border-1)]',
|
||||
},
|
||||
|
||||
@@ -14,11 +14,12 @@ const buttonVariants = cva(
|
||||
'3d': 'text-[var(--text-tertiary)] border-t border-l border-r border-[var(--border-1)] shadow-[0_2px_0_0_var(--border-1)] hover:shadow-[0_4px_0_0_var(--border-1)] transition-all hover:-translate-y-0.5 hover:text-[var(--text-primary)]',
|
||||
outline:
|
||||
'border border-[var(--text-muted)] bg-transparent hover:border-[var(--text-secondary)]',
|
||||
primary: 'bg-[var(--brand-400)] text-[var(--text-primary)] hover:brightness-106',
|
||||
primary:
|
||||
'bg-[#1D1D1D] text-[var(--text-inverse)] hover:text-[var(--text-inverse)] hover:bg-[#2A2A2A] dark:bg-white dark:hover:bg-[#E0E0E0]',
|
||||
destructive: 'bg-[var(--text-error)] text-white hover:text-white hover:brightness-106',
|
||||
secondary: 'bg-[var(--brand-secondary)] text-[var(--text-primary)]',
|
||||
tertiary:
|
||||
'!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:brightness-106 hover:!text-[var(--text-inverse)] ![transition-property:background-color,border-color,fill,stroke]',
|
||||
'!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!text-[var(--text-inverse)] hover:!bg-[#2DAC72] dark:!bg-[var(--brand-tertiary-2)] dark:hover:!bg-[#2DAC72] dark:!text-[var(--text-inverse)] dark:hover:!text-[var(--text-inverse)]',
|
||||
ghost: '',
|
||||
subtle: 'text-[var(--text-body)] hover:text-[var(--text-body)] hover:bg-[var(--surface-4)]',
|
||||
'ghost-secondary': 'text-[var(--text-muted)]',
|
||||
|
||||
@@ -197,7 +197,7 @@ const ModalHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDi
|
||||
({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex min-w-0 items-center justify-between gap-2 px-4 py-4', className)}
|
||||
className={cn('flex min-w-0 items-center justify-between gap-2 px-4 pt-4 pb-2', className)}
|
||||
{...props}
|
||||
>
|
||||
<DialogPrimitive.Title className='min-w-0 font-medium text-[var(--text-primary)] text-base leading-none'>
|
||||
@@ -296,7 +296,7 @@ const ModalTabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex gap-[16px] px-4 pt-3',
|
||||
'relative flex gap-[16px] px-4 pt-1',
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
className
|
||||
)}
|
||||
@@ -356,7 +356,7 @@ ModalTabsContent.displayName = 'ModalTabsContent'
|
||||
*/
|
||||
const ModalBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex-1 overflow-y-auto px-4 pb-4', className)} {...props} />
|
||||
<div ref={ref} className={cn('flex-1 overflow-y-auto px-4 pt-3 pb-4', className)} {...props} />
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
@@ -14,30 +12,11 @@ import {
|
||||
} from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Button } from '@/components/emcn/components/button/button'
|
||||
import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
const AUTO_DISMISS_MS = 10_000
|
||||
const AUTO_DISMISS_MS = 0
|
||||
const EXIT_ANIMATION_MS = 200
|
||||
const MAX_VISIBLE = 4
|
||||
const STACK_OFFSET_PX = 3
|
||||
|
||||
const RING_RADIUS = 5.5
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
|
||||
|
||||
const TOAST_KEYFRAMES = `
|
||||
@keyframes toast-enter {
|
||||
from { opacity: 0; transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97); }
|
||||
to { opacity: 1; transform: translateX(var(--stack-offset, 0px)) scale(1); }
|
||||
}
|
||||
@keyframes toast-exit {
|
||||
from { opacity: 1; transform: translateX(var(--stack-offset, 0px)) scale(1); }
|
||||
to { opacity: 0; transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97); }
|
||||
}
|
||||
@keyframes toast-countdown {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: ${RING_CIRCUMFERENCE.toFixed(2)}; }
|
||||
}`
|
||||
const MAX_VISIBLE = 20
|
||||
|
||||
type ToastVariant = 'default' | 'success' | 'error'
|
||||
|
||||
@@ -49,17 +28,16 @@ interface ToastAction {
|
||||
interface ToastData {
|
||||
id: string
|
||||
message: string
|
||||
description?: string
|
||||
variant: ToastVariant
|
||||
icon?: ReactElement
|
||||
action?: ToastAction
|
||||
duration: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
type ToastInput = {
|
||||
message: string
|
||||
description?: string
|
||||
variant?: ToastVariant
|
||||
icon?: ReactElement
|
||||
action?: ToastAction
|
||||
duration?: number
|
||||
}
|
||||
@@ -73,7 +51,6 @@ type ToastFn = {
|
||||
interface ToastContextValue {
|
||||
toast: ToastFn
|
||||
dismiss: (id: string) => void
|
||||
dismissAll: () => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
@@ -113,133 +90,81 @@ export function useToast() {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function CountdownRing({ durationMs, onPause }: { durationMs: number; onPause: () => void }) {
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={onPause}
|
||||
aria-label='Keep visible'
|
||||
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<svg
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 16 16'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
|
||||
>
|
||||
<circle cx='8' cy='8' r={RING_RADIUS} stroke='var(--border)' strokeWidth='1.5' />
|
||||
<circle
|
||||
cx='8'
|
||||
cy='8'
|
||||
r={RING_RADIUS}
|
||||
stroke='var(--text-icon)'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeDasharray={RING_CIRCUMFERENCE}
|
||||
style={{
|
||||
animation: `toast-countdown ${durationMs}ms linear forwards`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Keep visible</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
const VARIANT_STYLES: Record<ToastVariant, string> = {
|
||||
default: 'border-[var(--border)] bg-[var(--bg)] text-[var(--text-primary)]',
|
||||
success:
|
||||
'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/40 dark:bg-emerald-950/30 dark:text-emerald-200',
|
||||
error:
|
||||
'border-red-200 bg-red-50 text-red-900 dark:border-red-800/40 dark:bg-red-950/30 dark:text-red-200',
|
||||
}
|
||||
|
||||
const ToastItem = memo(function ToastItem({
|
||||
data,
|
||||
depth,
|
||||
isExiting,
|
||||
showCountdown,
|
||||
onDismiss,
|
||||
onPauseCountdown,
|
||||
onAction,
|
||||
}: {
|
||||
data: ToastData
|
||||
depth: number
|
||||
isExiting: boolean
|
||||
showCountdown: boolean
|
||||
onDismiss: (id: string) => void
|
||||
onPauseCountdown: () => void
|
||||
onAction: (id: string) => void
|
||||
}) {
|
||||
const xOffset = depth * STACK_OFFSET_PX
|
||||
function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id: string) => void }) {
|
||||
const [exiting, setExiting] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setExiting(true)
|
||||
setTimeout(() => onDismiss(t.id), EXIT_ANIMATION_MS)
|
||||
}, [onDismiss, t.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (t.duration > 0) {
|
||||
timerRef.current = setTimeout(dismiss, t.duration)
|
||||
return () => clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [dismiss, t.duration])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
'--stack-offset': `${xOffset}px`,
|
||||
animation: isExiting
|
||||
? `toast-exit ${EXIT_ANIMATION_MS}ms ease-in forwards`
|
||||
: 'toast-enter 200ms ease-out forwards',
|
||||
gridArea: '1 / 1',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className='w-[240px] self-end overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)] shadow-sm'
|
||||
className={cn(
|
||||
'pointer-events-auto flex w-[320px] items-start gap-[8px] rounded-[8px] border px-[12px] py-[10px] shadow-md transition-all',
|
||||
VARIANT_STYLES[t.variant],
|
||||
exiting
|
||||
? 'animate-[toast-exit_200ms_ease-in_forwards]'
|
||||
: 'animate-[toast-enter_200ms_ease-out_forwards]'
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-col gap-[8px] p-[8px]'>
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
{data.icon && (
|
||||
<span className='flex h-[16px] shrink-0 items-center text-[var(--text-icon)]'>
|
||||
{data.icon}
|
||||
</span>
|
||||
)}
|
||||
<div className='line-clamp-2 min-w-0 flex-1 font-medium text-[12px] text-[var(--text-body)]'>
|
||||
{data.variant === 'error' && (
|
||||
<span className='mr-[8px] mb-[2px] inline-block h-[8px] w-[8px] rounded-[2px] bg-[var(--text-error)] align-middle' />
|
||||
)}
|
||||
{data.message}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-start gap-[2px]'>
|
||||
{showCountdown && (
|
||||
<CountdownRing durationMs={data.duration} onPause={onPauseCountdown} />
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => onDismiss(data.id)}
|
||||
aria-label='Dismiss'
|
||||
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<X className='h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{data.action && (
|
||||
<Button
|
||||
variant='active'
|
||||
onClick={() => onAction(data.id)}
|
||||
className='w-full rounded-[5px] px-[8px] py-[4px] font-medium text-[12px]'
|
||||
>
|
||||
{data.action.label}
|
||||
</Button>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='font-medium text-[13px] leading-[18px]'>{t.message}</p>
|
||||
{t.description && (
|
||||
<p className='mt-[2px] text-[12px] leading-[16px] opacity-80'>{t.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{t.action && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
t.action!.onClick()
|
||||
dismiss()
|
||||
}}
|
||||
className='shrink-0 font-medium text-[13px] underline underline-offset-2 opacity-90 hover:opacity-100'
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type='button'
|
||||
onClick={dismiss}
|
||||
className='shrink-0 rounded-[4px] p-[2px] opacity-60 hover:opacity-100'
|
||||
>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast container that renders toasts via portal.
|
||||
* Mount once where you want toasts to appear. Renders stacked cards in the bottom-right.
|
||||
* Mount once in your root layout.
|
||||
*
|
||||
* Visual design matches the workflow notification component: 240px cards, stacked with
|
||||
* offset, countdown ring on auto-dismissing items, enter/exit animations.
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ToastProvider />
|
||||
* ```
|
||||
*/
|
||||
export function ToastProvider({ children }: { children?: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastData[]>([])
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
|
||||
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
@@ -250,87 +175,17 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
|
||||
const data: ToastData = {
|
||||
id,
|
||||
message: input.message,
|
||||
description: input.description,
|
||||
variant: input.variant ?? 'default',
|
||||
icon: input.icon,
|
||||
action: input.action,
|
||||
duration: input.duration ?? AUTO_DISMISS_MS,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
setToasts((prev) => [data, ...prev].slice(0, MAX_VISIBLE))
|
||||
setToasts((prev) => [...prev, data].slice(-MAX_VISIBLE))
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const dismissToast = useCallback((id: string) => {
|
||||
setExitingIds((prev) => new Set(prev).add(id))
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
setExitingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}, EXIT_ANIMATION_MS)
|
||||
}, [])
|
||||
|
||||
const dismissAll = useCallback(() => {
|
||||
setToasts([])
|
||||
setExitingIds(new Set())
|
||||
for (const timer of timersRef.current.values()) clearTimeout(timer)
|
||||
timersRef.current.clear()
|
||||
}, [])
|
||||
|
||||
const pauseAll = useCallback(() => {
|
||||
setIsPaused(true)
|
||||
setExitingIds(new Set())
|
||||
for (const timer of timersRef.current.values()) clearTimeout(timer)
|
||||
timersRef.current.clear()
|
||||
}, [])
|
||||
|
||||
const handleAction = useCallback(
|
||||
(id: string) => {
|
||||
const t = toasts.find((toast) => toast.id === id)
|
||||
if (t?.action) {
|
||||
t.action.onClick()
|
||||
dismissToast(id)
|
||||
}
|
||||
},
|
||||
[toasts, dismissToast]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (toasts.length === 0) {
|
||||
if (isPaused) setIsPaused(false)
|
||||
return
|
||||
}
|
||||
if (isPaused) return
|
||||
|
||||
const timers = timersRef.current
|
||||
|
||||
for (const t of toasts) {
|
||||
if (t.duration <= 0 || timers.has(t.id)) continue
|
||||
|
||||
timers.set(
|
||||
t.id,
|
||||
setTimeout(() => {
|
||||
timers.delete(t.id)
|
||||
dismissToast(t.id)
|
||||
}, t.duration)
|
||||
)
|
||||
}
|
||||
|
||||
for (const [id, timer] of timers) {
|
||||
if (!toasts.some((t) => t.id === id)) {
|
||||
clearTimeout(timer)
|
||||
timers.delete(id)
|
||||
}
|
||||
}
|
||||
}, [toasts, isPaused, dismissToast])
|
||||
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current
|
||||
return () => {
|
||||
for (const timer of timers.values()) clearTimeout(timer)
|
||||
}
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const toastFn = useRef<ToastFn>(createToastFn(addToast))
|
||||
@@ -344,44 +199,24 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
|
||||
}, [addToast])
|
||||
|
||||
const ctx = useMemo<ToastContextValue>(
|
||||
() => ({ toast: toastFn.current, dismiss: dismissToast, dismissAll }),
|
||||
[dismissToast, dismissAll]
|
||||
() => ({ toast: toastFn.current, dismiss: dismissToast }),
|
||||
[dismissToast]
|
||||
)
|
||||
|
||||
const visibleToasts = toasts.slice(0, MAX_VISIBLE)
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={ctx}>
|
||||
{children}
|
||||
{mounted &&
|
||||
visibleToasts.length > 0 &&
|
||||
createPortal(
|
||||
<>
|
||||
<style>{TOAST_KEYFRAMES}</style>
|
||||
<div
|
||||
aria-live='polite'
|
||||
aria-label='Toasts'
|
||||
className='fixed right-[16px] bottom-[16px] z-[10000400] grid'
|
||||
>
|
||||
{[...visibleToasts].reverse().map((t, index, stacked) => {
|
||||
const depth = stacked.length - index - 1
|
||||
const showCountdown = !isPaused && t.duration > 0
|
||||
|
||||
return (
|
||||
<ToastItem
|
||||
key={t.id}
|
||||
data={t}
|
||||
depth={depth}
|
||||
isExiting={exitingIds.has(t.id)}
|
||||
showCountdown={showCountdown}
|
||||
onDismiss={dismissToast}
|
||||
onPauseCountdown={pauseAll}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>,
|
||||
<div
|
||||
aria-live='polite'
|
||||
aria-label='Notifications'
|
||||
className='pointer-events-none fixed right-[16px] bottom-[16px] z-[10000400] flex flex-col-reverse items-end gap-[8px]'
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} toast={t} onDismiss={dismissToast} />
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
|
||||
@@ -85,17 +85,42 @@ export const airtableConnector: ConnectorConfig = {
|
||||
},
|
||||
|
||||
configFields: [
|
||||
{
|
||||
id: 'baseSelector',
|
||||
title: 'Base',
|
||||
type: 'selector',
|
||||
selectorKey: 'airtable.bases',
|
||||
canonicalParamId: 'baseId',
|
||||
mode: 'basic',
|
||||
placeholder: 'Select a base',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'baseId',
|
||||
title: 'Base ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'baseId',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. appXXXXXXXXXXXXXX',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'tableSelector',
|
||||
title: 'Table',
|
||||
type: 'selector',
|
||||
selectorKey: 'airtable.tables',
|
||||
canonicalParamId: 'tableIdOrName',
|
||||
mode: 'basic',
|
||||
dependsOn: ['baseSelector'],
|
||||
placeholder: 'Select a table',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'tableIdOrName',
|
||||
title: 'Table Name or ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'tableIdOrName',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. Tasks or tblXXXXXXXXXXXXXX',
|
||||
required: true,
|
||||
},
|
||||
|
||||
@@ -139,10 +139,22 @@ export const asanaConnector: ConnectorConfig = {
|
||||
auth: { mode: 'oauth', provider: 'asana', requiredScopes: ['default'] },
|
||||
|
||||
configFields: [
|
||||
{
|
||||
id: 'workspaceSelector',
|
||||
title: 'Workspace',
|
||||
type: 'selector',
|
||||
selectorKey: 'asana.workspaces',
|
||||
canonicalParamId: 'workspace',
|
||||
mode: 'basic',
|
||||
placeholder: 'Select a workspace',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
title: 'Workspace GID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'workspace',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. 1234567890',
|
||||
required: true,
|
||||
},
|
||||
|
||||
@@ -124,10 +124,23 @@ export const confluenceConnector: ConnectorConfig = {
|
||||
placeholder: 'yoursite.atlassian.net',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'spaceSelector',
|
||||
title: 'Space',
|
||||
type: 'selector',
|
||||
selectorKey: 'confluence.spaces',
|
||||
canonicalParamId: 'spaceKey',
|
||||
mode: 'basic',
|
||||
dependsOn: ['domain'],
|
||||
placeholder: 'Select a space',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'spaceKey',
|
||||
title: 'Space Key',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'spaceKey',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. ENG, PRODUCT',
|
||||
required: true,
|
||||
},
|
||||
|
||||
@@ -158,7 +158,11 @@ export const githubConnector: ConnectorConfig = {
|
||||
version: '1.0.0',
|
||||
icon: GithubIcon,
|
||||
|
||||
auth: { mode: 'oauth', provider: 'github', requiredScopes: ['repo'] },
|
||||
auth: {
|
||||
mode: 'apiKey',
|
||||
label: 'Personal Access Token',
|
||||
placeholder: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
},
|
||||
|
||||
configFields: [
|
||||
{
|
||||
|
||||
@@ -291,14 +291,27 @@ export const gmailConnector: ConnectorConfig = {
|
||||
auth: {
|
||||
mode: 'oauth',
|
||||
provider: 'google-email',
|
||||
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
|
||||
requiredScopes: ['https://www.googleapis.com/auth/gmail.modify'],
|
||||
},
|
||||
|
||||
configFields: [
|
||||
{
|
||||
id: 'labelSelector',
|
||||
title: 'Label',
|
||||
type: 'selector',
|
||||
selectorKey: 'gmail.labels',
|
||||
canonicalParamId: 'label',
|
||||
mode: 'basic',
|
||||
placeholder: 'Select a label',
|
||||
required: false,
|
||||
description: 'Only sync emails with this label. Leave empty for all mail.',
|
||||
},
|
||||
{
|
||||
id: 'label',
|
||||
title: 'Label',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'label',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. INBOX, IMPORTANT, or a custom label name',
|
||||
required: false,
|
||||
description: 'Only sync emails with this label. Leave empty for all mail.',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user