mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
20 Commits
fix/mother
...
fix/render
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74fe25cacc | ||
|
|
5e95d33705 | ||
|
|
20a626573d | ||
|
|
15429244f1 | ||
|
|
f077751ce8 | ||
|
|
75bdf46e6b | ||
|
|
952915abfc | ||
|
|
cbc9f4248c | ||
|
|
5ba3118495 | ||
|
|
00ff21ab9c | ||
|
|
a2f8ed06c8 | ||
|
|
f347e3fca0 | ||
|
|
e13f52fea2 | ||
|
|
e6b2b739cf | ||
|
|
9ae656c0d5 | ||
|
|
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>
|
||||
)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -33,11 +33,26 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
html[data-sidebar-collapsed] .sidebar-container span,
|
||||
html[data-sidebar-collapsed] .sidebar-container .text-small {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-container .sidebar-collapse-hide {
|
||||
transition: opacity 60ms ease;
|
||||
}
|
||||
|
||||
.sidebar-container[data-collapsed] .sidebar-collapse-hide {
|
||||
.sidebar-container[data-collapsed] .sidebar-collapse-hide,
|
||||
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -790,6 +805,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%,
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* POST /api/attribution
|
||||
*
|
||||
* Automatic UTM-based referral attribution.
|
||||
*
|
||||
* Reads the `sim_utm` cookie (set by proxy on auth pages), matches a campaign
|
||||
* by UTM specificity, and atomically inserts an attribution record + applies
|
||||
* bonus credits.
|
||||
*
|
||||
* Idempotent — the unique constraint on `userId` prevents double-attribution.
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
|
||||
|
||||
const logger = createLogger('AttributionAPI')
|
||||
|
||||
const COOKIE_NAME = 'sim_utm'
|
||||
|
||||
const UtmCookieSchema = z.object({
|
||||
utm_source: z.string().optional(),
|
||||
utm_medium: z.string().optional(),
|
||||
utm_campaign: z.string().optional(),
|
||||
utm_content: z.string().optional(),
|
||||
referrer_url: z.string().optional(),
|
||||
landing_page: z.string().optional(),
|
||||
created_at: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Finds the most specific active campaign matching the given UTM params.
|
||||
* Null fields on a campaign act as wildcards. Ties broken by newest campaign.
|
||||
*/
|
||||
async function findMatchingCampaign(utmData: z.infer<typeof UtmCookieSchema>) {
|
||||
const campaigns = await db
|
||||
.select()
|
||||
.from(referralCampaigns)
|
||||
.where(eq(referralCampaigns.isActive, true))
|
||||
|
||||
let bestMatch: (typeof campaigns)[number] | null = null
|
||||
let bestScore = -1
|
||||
|
||||
for (const campaign of campaigns) {
|
||||
let score = 0
|
||||
let mismatch = false
|
||||
|
||||
const fields = [
|
||||
{ campaignVal: campaign.utmSource, utmVal: utmData.utm_source },
|
||||
{ campaignVal: campaign.utmMedium, utmVal: utmData.utm_medium },
|
||||
{ campaignVal: campaign.utmCampaign, utmVal: utmData.utm_campaign },
|
||||
{ campaignVal: campaign.utmContent, utmVal: utmData.utm_content },
|
||||
] as const
|
||||
|
||||
for (const { campaignVal, utmVal } of fields) {
|
||||
if (campaignVal === null) continue
|
||||
if (campaignVal === utmVal) {
|
||||
score++
|
||||
} else {
|
||||
mismatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!mismatch && score > 0) {
|
||||
if (
|
||||
score > bestScore ||
|
||||
(score === bestScore &&
|
||||
bestMatch &&
|
||||
campaign.createdAt.getTime() > bestMatch.createdAt.getTime())
|
||||
) {
|
||||
bestScore = score
|
||||
bestMatch = campaign
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const utmCookie = cookieStore.get(COOKIE_NAME)
|
||||
if (!utmCookie?.value) {
|
||||
return NextResponse.json({ attributed: false, reason: 'no_utm_cookie' })
|
||||
}
|
||||
|
||||
let utmData: z.infer<typeof UtmCookieSchema>
|
||||
try {
|
||||
let decoded: string
|
||||
try {
|
||||
decoded = decodeURIComponent(utmCookie.value)
|
||||
} catch {
|
||||
decoded = utmCookie.value
|
||||
}
|
||||
utmData = UtmCookieSchema.parse(JSON.parse(decoded))
|
||||
} catch {
|
||||
logger.warn('Failed to parse UTM cookie', { userId: session.user.id })
|
||||
cookieStore.delete(COOKIE_NAME)
|
||||
return NextResponse.json({ attributed: false, reason: 'invalid_cookie' })
|
||||
}
|
||||
|
||||
const matchedCampaign = await findMatchingCampaign(utmData)
|
||||
if (!matchedCampaign) {
|
||||
cookieStore.delete(COOKIE_NAME)
|
||||
return NextResponse.json({ attributed: false, reason: 'no_matching_campaign' })
|
||||
}
|
||||
|
||||
const bonusAmount = Number(matchedCampaign.bonusCreditAmount)
|
||||
|
||||
let attributed = false
|
||||
await db.transaction(async (tx) => {
|
||||
const [existingStats] = await tx
|
||||
.select({ id: userStats.id })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (!existingStats) {
|
||||
await tx.insert(userStats).values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await tx
|
||||
.insert(referralAttribution)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
campaignId: matchedCampaign.id,
|
||||
utmSource: utmData.utm_source || null,
|
||||
utmMedium: utmData.utm_medium || null,
|
||||
utmCampaign: utmData.utm_campaign || null,
|
||||
utmContent: utmData.utm_content || null,
|
||||
referrerUrl: utmData.referrer_url || null,
|
||||
landingPage: utmData.landing_page || null,
|
||||
bonusCreditAmount: bonusAmount.toString(),
|
||||
})
|
||||
.onConflictDoNothing({ target: referralAttribution.userId })
|
||||
.returning({ id: referralAttribution.id })
|
||||
|
||||
if (result.length > 0) {
|
||||
await applyBonusCredits(session.user.id, bonusAmount, tx)
|
||||
attributed = true
|
||||
}
|
||||
})
|
||||
|
||||
if (attributed) {
|
||||
logger.info('Referral attribution created and bonus credits applied', {
|
||||
userId: session.user.id,
|
||||
campaignId: matchedCampaign.id,
|
||||
campaignName: matchedCampaign.name,
|
||||
utmSource: utmData.utm_source,
|
||||
utmCampaign: utmData.utm_campaign,
|
||||
utmContent: utmData.utm_content,
|
||||
bonusAmount,
|
||||
})
|
||||
} else {
|
||||
logger.info('User already attributed, skipping', { userId: session.user.id })
|
||||
}
|
||||
|
||||
cookieStore.delete(COOKIE_NAME)
|
||||
|
||||
return NextResponse.json({
|
||||
attributed,
|
||||
bonusAmount: attributed ? bonusAmount : undefined,
|
||||
reason: attributed ? undefined : 'already_attributed',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Attribution error', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -120,8 +120,8 @@ export async function verifyFileAccess(
|
||||
return true
|
||||
}
|
||||
|
||||
// 1. Workspace files: Check database first (most reliable for both local and cloud)
|
||||
if (inferredContext === 'workspace') {
|
||||
// 1. Workspace / mothership files: Check database first (most reliable for both local and cloud)
|
||||
if (inferredContext === 'workspace' || inferredContext === 'mothership') {
|
||||
return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { sanitizeFileName } from '@/executor/constants'
|
||||
import '@/lib/uploads/core/setup.server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { StorageContext } from '@/lib/uploads/config'
|
||||
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils'
|
||||
import {
|
||||
SUPPORTED_AUDIO_EXTENSIONS,
|
||||
@@ -46,9 +47,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')
|
||||
}
|
||||
|
||||
@@ -73,7 +75,7 @@ export async function POST(request: NextRequest) {
|
||||
const uploadResults = []
|
||||
|
||||
for (const file of files) {
|
||||
const originalName = file.name
|
||||
const originalName = file.name || 'untitled'
|
||||
|
||||
if (!validateFileExtension(originalName)) {
|
||||
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'
|
||||
@@ -231,6 +233,53 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mothership context (chat-scoped uploads to workspace S3)
|
||||
if (context === 'mothership') {
|
||||
if (!workspaceId) {
|
||||
throw new InvalidRequestError('Mothership context requires workspaceId parameter')
|
||||
}
|
||||
|
||||
logger.info(`Uploading mothership file: ${originalName}`)
|
||||
|
||||
const storageKey = generateWorkspaceFileKey(workspaceId, originalName)
|
||||
|
||||
const metadata: Record<string, string> = {
|
||||
originalName: originalName,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
purpose: 'mothership',
|
||||
userId: session.user.id,
|
||||
workspaceId,
|
||||
}
|
||||
|
||||
const fileInfo = await storageService.uploadFile({
|
||||
file: buffer,
|
||||
fileName: storageKey,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
context: 'mothership',
|
||||
preserveKey: true,
|
||||
customKey: storageKey,
|
||||
metadata,
|
||||
})
|
||||
|
||||
const finalPath = usingCloudStorage ? `${fileInfo.path}?context=mothership` : fileInfo.path
|
||||
|
||||
uploadResults.push({
|
||||
fileName: originalName,
|
||||
presignedUrl: '',
|
||||
fileInfo: {
|
||||
path: finalPath,
|
||||
key: fileInfo.key,
|
||||
name: originalName,
|
||||
size: buffer.length,
|
||||
type: file.type || 'application/octet-stream',
|
||||
},
|
||||
directUploadSupported: false,
|
||||
})
|
||||
|
||||
logger.info(`Successfully uploaded mothership file: ${fileInfo.key}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle copilot, chat, profile-pictures contexts
|
||||
if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') {
|
||||
if (context === 'copilot') {
|
||||
|
||||
@@ -31,6 +31,8 @@ const FileAttachmentSchema = z.object({
|
||||
const ResourceAttachmentSchema = z.object({
|
||||
type: z.enum(['workflow', 'table', 'file', 'knowledgebase']),
|
||||
id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
active: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const MothershipMessageSchema = z.object({
|
||||
@@ -124,9 +126,19 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
if (Array.isArray(resourceAttachments) && resourceAttachments.length > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
resourceAttachments.map((r) =>
|
||||
resolveActiveResourceContext(r.type, r.id, workspaceId, authenticatedUserId)
|
||||
)
|
||||
resourceAttachments.map(async (r) => {
|
||||
const ctx = await resolveActiveResourceContext(
|
||||
r.type,
|
||||
r.id,
|
||||
workspaceId,
|
||||
authenticatedUserId
|
||||
)
|
||||
if (!ctx) return null
|
||||
return {
|
||||
...ctx,
|
||||
tag: r.active ? '@active_tab' : '@open_tab',
|
||||
}
|
||||
})
|
||||
)
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
/**
|
||||
* POST /api/referral-code/redeem
|
||||
*
|
||||
* Redeem a referral/promo code to receive bonus credits.
|
||||
*
|
||||
* Body:
|
||||
* - code: string — The referral code to redeem
|
||||
*
|
||||
* Response: { redeemed: boolean, bonusAmount?: number, error?: string }
|
||||
*
|
||||
* Constraints:
|
||||
* - Enterprise users cannot redeem codes
|
||||
* - One redemption per user, ever (unique constraint on userId)
|
||||
* - One redemption per organization for team users (partial unique on organizationId)
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
|
||||
import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers'
|
||||
|
||||
const logger = createLogger('ReferralCodeRedemption')
|
||||
|
||||
const RedeemCodeSchema = z.object({
|
||||
code: z.string().min(1, 'Code is required'),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { code } = RedeemCodeSchema.parse(body)
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(session.user.id)
|
||||
|
||||
if (isEnterprise(subscription?.plan)) {
|
||||
return NextResponse.json({
|
||||
redeemed: false,
|
||||
error: 'Enterprise accounts cannot redeem referral codes',
|
||||
})
|
||||
}
|
||||
|
||||
const isTeamSub = isTeam(subscription?.plan)
|
||||
const orgId = isTeamSub ? subscription!.referenceId : null
|
||||
|
||||
const normalizedCode = code.trim().toUpperCase()
|
||||
|
||||
const [campaign] = await db
|
||||
.select()
|
||||
.from(referralCampaigns)
|
||||
.where(and(eq(referralCampaigns.code, normalizedCode), eq(referralCampaigns.isActive, true)))
|
||||
.limit(1)
|
||||
|
||||
if (!campaign) {
|
||||
logger.info('Invalid code redemption attempt', {
|
||||
userId: session.user.id,
|
||||
code: normalizedCode,
|
||||
})
|
||||
return NextResponse.json({ error: 'Invalid or expired code' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [existingUserAttribution] = await db
|
||||
.select({ id: referralAttribution.id })
|
||||
.from(referralAttribution)
|
||||
.where(eq(referralAttribution.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (existingUserAttribution) {
|
||||
return NextResponse.json({
|
||||
redeemed: false,
|
||||
error: 'You have already redeemed a code',
|
||||
})
|
||||
}
|
||||
|
||||
if (orgId) {
|
||||
const [existingOrgAttribution] = await db
|
||||
.select({ id: referralAttribution.id })
|
||||
.from(referralAttribution)
|
||||
.where(eq(referralAttribution.organizationId, orgId))
|
||||
.limit(1)
|
||||
|
||||
if (existingOrgAttribution) {
|
||||
return NextResponse.json({
|
||||
redeemed: false,
|
||||
error: 'A code has already been redeemed for your organization',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const bonusAmount = Number(campaign.bonusCreditAmount)
|
||||
|
||||
let redeemed = false
|
||||
await db.transaction(async (tx) => {
|
||||
const [existingStats] = await tx
|
||||
.select({ id: userStats.id })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (!existingStats) {
|
||||
await tx.insert(userStats).values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await tx
|
||||
.insert(referralAttribution)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
organizationId: orgId,
|
||||
campaignId: campaign.id,
|
||||
utmSource: null,
|
||||
utmMedium: null,
|
||||
utmCampaign: null,
|
||||
utmContent: null,
|
||||
referrerUrl: null,
|
||||
landingPage: null,
|
||||
bonusCreditAmount: bonusAmount.toString(),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: referralAttribution.id })
|
||||
|
||||
if (result.length > 0) {
|
||||
await applyBonusCredits(session.user.id, bonusAmount, tx)
|
||||
redeemed = true
|
||||
}
|
||||
})
|
||||
|
||||
if (redeemed) {
|
||||
logger.info('Referral code redeemed', {
|
||||
userId: session.user.id,
|
||||
organizationId: orgId,
|
||||
code: normalizedCode,
|
||||
campaignId: campaign.id,
|
||||
campaignName: campaign.name,
|
||||
bonusAmount,
|
||||
})
|
||||
}
|
||||
|
||||
if (!redeemed) {
|
||||
return NextResponse.json({
|
||||
redeemed: false,
|
||||
error: 'You have already redeemed a code',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
redeemed: true,
|
||||
bonusAmount,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
|
||||
}
|
||||
logger.error('Referral code redemption error', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,6 @@ export type {
|
||||
AdminOrganization,
|
||||
AdminOrganizationBillingSummary,
|
||||
AdminOrganizationDetail,
|
||||
AdminReferralCampaign,
|
||||
AdminSeatAnalytics,
|
||||
AdminSingleResponse,
|
||||
AdminSubscription,
|
||||
@@ -118,7 +117,6 @@ export type {
|
||||
AdminWorkspaceMember,
|
||||
DbMember,
|
||||
DbOrganization,
|
||||
DbReferralCampaign,
|
||||
DbSubscription,
|
||||
DbUser,
|
||||
DbUserStats,
|
||||
@@ -147,7 +145,6 @@ export {
|
||||
parseWorkflowVariables,
|
||||
toAdminFolder,
|
||||
toAdminOrganization,
|
||||
toAdminReferralCampaign,
|
||||
toAdminSubscription,
|
||||
toAdminUser,
|
||||
toAdminWorkflow,
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* GET /api/v1/admin/referral-campaigns/:id
|
||||
*
|
||||
* Get a single referral campaign by ID.
|
||||
*
|
||||
* PATCH /api/v1/admin/referral-campaigns/:id
|
||||
*
|
||||
* Update campaign fields. All fields are optional.
|
||||
*
|
||||
* Body:
|
||||
* - name: string (non-empty) - Campaign name
|
||||
* - bonusCreditAmount: number (> 0) - Bonus credits in dollars
|
||||
* - isActive: boolean - Enable/disable the campaign
|
||||
* - code: string | null (min 6 chars, auto-uppercased, null to remove) - Redeemable code
|
||||
* - utmSource: string | null - UTM source match (null = wildcard)
|
||||
* - utmMedium: string | null - UTM medium match (null = wildcard)
|
||||
* - utmCampaign: string | null - UTM campaign match (null = wildcard)
|
||||
* - utmContent: string | null - UTM content match (null = wildcard)
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { referralCampaigns } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import { toAdminReferralCampaign } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminReferralCampaignDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
try {
|
||||
const { id: campaignId } = await context.params
|
||||
|
||||
const [campaign] = await db
|
||||
.select()
|
||||
.from(referralCampaigns)
|
||||
.where(eq(referralCampaigns.id, campaignId))
|
||||
.limit(1)
|
||||
|
||||
if (!campaign) {
|
||||
return notFoundResponse('Campaign')
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved referral campaign ${campaignId}`)
|
||||
|
||||
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get referral campaign', { error })
|
||||
return internalErrorResponse('Failed to get referral campaign')
|
||||
}
|
||||
})
|
||||
|
||||
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
try {
|
||||
const { id: campaignId } = await context.params
|
||||
const body = await request.json()
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(referralCampaigns)
|
||||
.where(eq(referralCampaigns.id, campaignId))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
return notFoundResponse('Campaign')
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() }
|
||||
|
||||
if (body.name !== undefined) {
|
||||
if (typeof body.name !== 'string' || body.name.trim().length === 0) {
|
||||
return badRequestResponse('name must be a non-empty string')
|
||||
}
|
||||
updateData.name = body.name.trim()
|
||||
}
|
||||
|
||||
if (body.bonusCreditAmount !== undefined) {
|
||||
if (
|
||||
typeof body.bonusCreditAmount !== 'number' ||
|
||||
!Number.isFinite(body.bonusCreditAmount) ||
|
||||
body.bonusCreditAmount <= 0
|
||||
) {
|
||||
return badRequestResponse('bonusCreditAmount must be a positive number')
|
||||
}
|
||||
updateData.bonusCreditAmount = body.bonusCreditAmount.toString()
|
||||
}
|
||||
|
||||
if (body.isActive !== undefined) {
|
||||
if (typeof body.isActive !== 'boolean') {
|
||||
return badRequestResponse('isActive must be a boolean')
|
||||
}
|
||||
updateData.isActive = body.isActive
|
||||
}
|
||||
|
||||
if (body.code !== undefined) {
|
||||
if (body.code !== null) {
|
||||
if (typeof body.code !== 'string') {
|
||||
return badRequestResponse('code must be a string or null')
|
||||
}
|
||||
if (body.code.trim().length < 6) {
|
||||
return badRequestResponse('code must be at least 6 characters')
|
||||
}
|
||||
}
|
||||
updateData.code = body.code ? body.code.trim().toUpperCase() : null
|
||||
}
|
||||
|
||||
for (const field of ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent'] as const) {
|
||||
if (body[field] !== undefined) {
|
||||
if (body[field] !== null && typeof body[field] !== 'string') {
|
||||
return badRequestResponse(`${field} must be a string or null`)
|
||||
}
|
||||
updateData[field] = body[field] || null
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(referralCampaigns)
|
||||
.set(updateData)
|
||||
.where(eq(referralCampaigns.id, campaignId))
|
||||
.returning()
|
||||
|
||||
logger.info(`Admin API: Updated referral campaign ${campaignId}`, {
|
||||
fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
|
||||
})
|
||||
|
||||
return singleResponse(toAdminReferralCampaign(updated, getBaseUrl()))
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update referral campaign', { error })
|
||||
return internalErrorResponse('Failed to update referral campaign')
|
||||
}
|
||||
})
|
||||
@@ -1,104 +1,160 @@
|
||||
/**
|
||||
* GET /api/v1/admin/referral-campaigns
|
||||
*
|
||||
* List referral campaigns with optional filtering and pagination.
|
||||
* List Stripe promotion codes with cursor-based pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - active: string (optional) - Filter by active status ('true' or 'false')
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
* - limit: number (default: 50, max: 100)
|
||||
* - starting_after: string (cursor — Stripe promotion code ID)
|
||||
* - active: 'true' | 'false' (optional filter)
|
||||
*
|
||||
* POST /api/v1/admin/referral-campaigns
|
||||
*
|
||||
* Create a new referral campaign.
|
||||
* Create a Stripe coupon and an associated promotion code.
|
||||
*
|
||||
* Body:
|
||||
* - name: string (required) - Campaign name
|
||||
* - bonusCreditAmount: number (required, > 0) - Bonus credits in dollars
|
||||
* - code: string | null (optional, min 6 chars, auto-uppercased) - Redeemable code
|
||||
* - utmSource: string | null (optional) - UTM source match (null = wildcard)
|
||||
* - utmMedium: string | null (optional) - UTM medium match (null = wildcard)
|
||||
* - utmCampaign: string | null (optional) - UTM campaign match (null = wildcard)
|
||||
* - utmContent: string | null (optional) - UTM content match (null = wildcard)
|
||||
* - name: string (required) — Display name for the coupon
|
||||
* - percentOff: number (required, 1–100) — Percentage discount
|
||||
* - code: string | null (optional, min 6 chars, auto-uppercased) — Desired code
|
||||
* - duration: 'once' | 'repeating' | 'forever' (default: 'once')
|
||||
* - durationInMonths: number (required when duration is 'repeating')
|
||||
* - maxRedemptions: number (optional) — Total redemption cap
|
||||
* - expiresAt: ISO 8601 string (optional) — Promotion code expiry
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { referralCampaigns } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count, eq, type SQL } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type Stripe from 'stripe'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
listResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminReferralCampaign,
|
||||
createPaginationMeta,
|
||||
parsePaginationParams,
|
||||
toAdminReferralCampaign,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminReferralCampaignsAPI')
|
||||
const logger = createLogger('AdminPromoCodes')
|
||||
|
||||
const VALID_DURATIONS = ['once', 'repeating', 'forever'] as const
|
||||
type Duration = (typeof VALID_DURATIONS)[number]
|
||||
|
||||
interface PromoCodeResponse {
|
||||
id: string
|
||||
code: string
|
||||
couponId: string
|
||||
name: string
|
||||
percentOff: number
|
||||
duration: string
|
||||
durationInMonths: number | null
|
||||
maxRedemptions: number | null
|
||||
expiresAt: string | null
|
||||
active: boolean
|
||||
timesRedeemed: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function formatPromoCode(promo: {
|
||||
id: string
|
||||
code: string
|
||||
coupon: {
|
||||
id: string
|
||||
name: string | null
|
||||
percent_off: number | null
|
||||
duration: string
|
||||
duration_in_months: number | null
|
||||
}
|
||||
max_redemptions: number | null
|
||||
expires_at: number | null
|
||||
active: boolean
|
||||
times_redeemed: number
|
||||
created: number
|
||||
}): PromoCodeResponse {
|
||||
return {
|
||||
id: promo.id,
|
||||
code: promo.code,
|
||||
couponId: promo.coupon.id,
|
||||
name: promo.coupon.name ?? '',
|
||||
percentOff: promo.coupon.percent_off ?? 0,
|
||||
duration: promo.coupon.duration,
|
||||
durationInMonths: promo.coupon.duration_in_months,
|
||||
maxRedemptions: promo.max_redemptions,
|
||||
expiresAt: promo.expires_at ? new Date(promo.expires_at * 1000).toISOString() : null,
|
||||
active: promo.active,
|
||||
timesRedeemed: promo.times_redeemed,
|
||||
createdAt: new Date(promo.created * 1000).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withAdminAuth(async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const { limit, offset } = parsePaginationParams(url)
|
||||
const activeFilter = url.searchParams.get('active')
|
||||
|
||||
try {
|
||||
const conditions: SQL<unknown>[] = []
|
||||
if (activeFilter === 'true') {
|
||||
conditions.push(eq(referralCampaigns.isActive, true))
|
||||
} else if (activeFilter === 'false') {
|
||||
conditions.push(eq(referralCampaigns.isActive, false))
|
||||
}
|
||||
const stripe = requireStripeClient()
|
||||
const url = new URL(request.url)
|
||||
|
||||
const whereClause = conditions.length > 0 ? conditions[0] : undefined
|
||||
const baseUrl = getBaseUrl()
|
||||
const limitParam = url.searchParams.get('limit')
|
||||
let limit = limitParam ? Number.parseInt(limitParam, 10) : 50
|
||||
if (Number.isNaN(limit) || limit < 1) limit = 50
|
||||
if (limit > 100) limit = 100
|
||||
|
||||
const [countResult, campaigns] = await Promise.all([
|
||||
db.select({ total: count() }).from(referralCampaigns).where(whereClause),
|
||||
db
|
||||
.select()
|
||||
.from(referralCampaigns)
|
||||
.where(whereClause)
|
||||
.orderBy(referralCampaigns.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
const startingAfter = url.searchParams.get('starting_after') || undefined
|
||||
const activeFilter = url.searchParams.get('active')
|
||||
|
||||
const total = countResult[0].total
|
||||
const data: AdminReferralCampaign[] = campaigns.map((c) => toAdminReferralCampaign(c, baseUrl))
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
const listParams: Record<string, unknown> = { limit }
|
||||
if (startingAfter) listParams.starting_after = startingAfter
|
||||
if (activeFilter === 'true') listParams.active = true
|
||||
else if (activeFilter === 'false') listParams.active = false
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} referral campaigns (total: ${total})`)
|
||||
const promoCodes = await stripe.promotionCodes.list(listParams)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
const data = promoCodes.data.map(formatPromoCode)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} Stripe promotion codes`)
|
||||
|
||||
return NextResponse.json({
|
||||
data,
|
||||
hasMore: promoCodes.has_more,
|
||||
...(data.length > 0 ? { nextCursor: data[data.length - 1].id } : {}),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list referral campaigns', { error })
|
||||
return internalErrorResponse('Failed to list referral campaigns')
|
||||
logger.error('Admin API: Failed to list promotion codes', { error })
|
||||
return internalErrorResponse('Failed to list promotion codes')
|
||||
}
|
||||
})
|
||||
|
||||
export const POST = withAdminAuth(async (request) => {
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
const body = await request.json()
|
||||
const { name, code, utmSource, utmMedium, utmCampaign, utmContent, bonusCreditAmount } = body
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return badRequestResponse('name is required and must be a string')
|
||||
const { name, percentOff, code, duration, durationInMonths, maxRedemptions, expiresAt } = body
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return badRequestResponse('name is required and must be a non-empty string')
|
||||
}
|
||||
|
||||
if (
|
||||
typeof bonusCreditAmount !== 'number' ||
|
||||
!Number.isFinite(bonusCreditAmount) ||
|
||||
bonusCreditAmount <= 0
|
||||
typeof percentOff !== 'number' ||
|
||||
!Number.isFinite(percentOff) ||
|
||||
percentOff < 1 ||
|
||||
percentOff > 100
|
||||
) {
|
||||
return badRequestResponse('bonusCreditAmount must be a positive number')
|
||||
return badRequestResponse('percentOff must be a number between 1 and 100')
|
||||
}
|
||||
|
||||
const effectiveDuration: Duration = duration ?? 'once'
|
||||
if (!VALID_DURATIONS.includes(effectiveDuration)) {
|
||||
return badRequestResponse(`duration must be one of: ${VALID_DURATIONS.join(', ')}`)
|
||||
}
|
||||
|
||||
if (effectiveDuration === 'repeating') {
|
||||
if (
|
||||
typeof durationInMonths !== 'number' ||
|
||||
!Number.isInteger(durationInMonths) ||
|
||||
durationInMonths < 1
|
||||
) {
|
||||
return badRequestResponse(
|
||||
'durationInMonths is required and must be a positive integer when duration is "repeating"'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (code !== undefined && code !== null) {
|
||||
@@ -110,31 +166,77 @@ export const POST = withAdminAuth(async (request) => {
|
||||
}
|
||||
}
|
||||
|
||||
const id = nanoid()
|
||||
if (maxRedemptions !== undefined && maxRedemptions !== null) {
|
||||
if (
|
||||
typeof maxRedemptions !== 'number' ||
|
||||
!Number.isInteger(maxRedemptions) ||
|
||||
maxRedemptions < 1
|
||||
) {
|
||||
return badRequestResponse('maxRedemptions must be a positive integer')
|
||||
}
|
||||
}
|
||||
|
||||
const [campaign] = await db
|
||||
.insert(referralCampaigns)
|
||||
.values({
|
||||
id,
|
||||
name,
|
||||
code: code ? code.trim().toUpperCase() : null,
|
||||
utmSource: utmSource || null,
|
||||
utmMedium: utmMedium || null,
|
||||
utmCampaign: utmCampaign || null,
|
||||
utmContent: utmContent || null,
|
||||
bonusCreditAmount: bonusCreditAmount.toString(),
|
||||
})
|
||||
.returning()
|
||||
if (expiresAt !== undefined && expiresAt !== null) {
|
||||
const parsed = new Date(expiresAt)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return badRequestResponse('expiresAt must be a valid ISO 8601 date string')
|
||||
}
|
||||
if (parsed.getTime() <= Date.now()) {
|
||||
return badRequestResponse('expiresAt must be in the future')
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Created referral campaign ${id}`, {
|
||||
name,
|
||||
code: campaign.code,
|
||||
bonusCreditAmount,
|
||||
const coupon = await stripe.coupons.create({
|
||||
name: name.trim(),
|
||||
percent_off: percentOff,
|
||||
duration: effectiveDuration,
|
||||
...(effectiveDuration === 'repeating' ? { duration_in_months: durationInMonths } : {}),
|
||||
})
|
||||
|
||||
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
|
||||
let promoCode
|
||||
try {
|
||||
const promoParams: Stripe.PromotionCodeCreateParams = {
|
||||
coupon: coupon.id,
|
||||
...(code ? { code: code.trim().toUpperCase() } : {}),
|
||||
...(maxRedemptions ? { max_redemptions: maxRedemptions } : {}),
|
||||
...(expiresAt ? { expires_at: Math.floor(new Date(expiresAt).getTime() / 1000) } : {}),
|
||||
}
|
||||
|
||||
promoCode = await stripe.promotionCodes.create(promoParams)
|
||||
} catch (promoError) {
|
||||
try {
|
||||
await stripe.coupons.del(coupon.id)
|
||||
} catch (cleanupError) {
|
||||
logger.error(
|
||||
'Admin API: Failed to clean up orphaned coupon after promo code creation failed',
|
||||
{
|
||||
couponId: coupon.id,
|
||||
cleanupError,
|
||||
}
|
||||
)
|
||||
}
|
||||
throw promoError
|
||||
}
|
||||
|
||||
logger.info('Admin API: Created Stripe promotion code', {
|
||||
promoCodeId: promoCode.id,
|
||||
code: promoCode.code,
|
||||
couponId: coupon.id,
|
||||
percentOff,
|
||||
duration: effectiveDuration,
|
||||
})
|
||||
|
||||
return singleResponse(formatPromoCode(promoCode))
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to create referral campaign', { error })
|
||||
return internalErrorResponse('Failed to create referral campaign')
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'type' in error &&
|
||||
(error as { type: string }).type === 'StripeInvalidRequestError'
|
||||
) {
|
||||
logger.warn('Admin API: Stripe rejected promotion code request', { error: error.message })
|
||||
return badRequestResponse(error.message)
|
||||
}
|
||||
logger.error('Admin API: Failed to create promotion code', { error })
|
||||
return internalErrorResponse('Failed to create promotion code')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
auditLog,
|
||||
member,
|
||||
organization,
|
||||
referralCampaigns,
|
||||
subscription,
|
||||
user,
|
||||
userStats,
|
||||
@@ -33,7 +32,6 @@ export type DbOrganization = InferSelectModel<typeof organization>
|
||||
export type DbSubscription = InferSelectModel<typeof subscription>
|
||||
export type DbMember = InferSelectModel<typeof member>
|
||||
export type DbUserStats = InferSelectModel<typeof userStats>
|
||||
export type DbReferralCampaign = InferSelectModel<typeof referralCampaigns>
|
||||
|
||||
// =============================================================================
|
||||
// Pagination
|
||||
@@ -650,52 +648,6 @@ export interface AdminUndeployResult {
|
||||
isDeployed: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Referral Campaign Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminReferralCampaign {
|
||||
id: string
|
||||
name: string
|
||||
code: string | null
|
||||
utmSource: string | null
|
||||
utmMedium: string | null
|
||||
utmCampaign: string | null
|
||||
utmContent: string | null
|
||||
bonusCreditAmount: string
|
||||
isActive: boolean
|
||||
signupUrl: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export function toAdminReferralCampaign(
|
||||
dbCampaign: DbReferralCampaign,
|
||||
baseUrl: string
|
||||
): AdminReferralCampaign {
|
||||
const utmParams = new URLSearchParams()
|
||||
if (dbCampaign.utmSource) utmParams.set('utm_source', dbCampaign.utmSource)
|
||||
if (dbCampaign.utmMedium) utmParams.set('utm_medium', dbCampaign.utmMedium)
|
||||
if (dbCampaign.utmCampaign) utmParams.set('utm_campaign', dbCampaign.utmCampaign)
|
||||
if (dbCampaign.utmContent) utmParams.set('utm_content', dbCampaign.utmContent)
|
||||
const query = utmParams.toString()
|
||||
|
||||
return {
|
||||
id: dbCampaign.id,
|
||||
name: dbCampaign.name,
|
||||
code: dbCampaign.code,
|
||||
utmSource: dbCampaign.utmSource,
|
||||
utmMedium: dbCampaign.utmMedium,
|
||||
utmCampaign: dbCampaign.utmCampaign,
|
||||
utmContent: dbCampaign.utmContent,
|
||||
bonusCreditAmount: dbCampaign.bonusCreditAmount,
|
||||
isActive: dbCampaign.isActive,
|
||||
signupUrl: query ? `${baseUrl}/signup?${query}` : null,
|
||||
createdAt: dbCampaign.createdAt.toISOString(),
|
||||
updatedAt: dbCampaign.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Audit Log Types
|
||||
// =============================================================================
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
} from '@/lib/execution/call-chain'
|
||||
import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer'
|
||||
import { processInputFileFields } from '@/lib/execution/files'
|
||||
import {
|
||||
registerManualExecutionAborter,
|
||||
unregisterManualExecutionAborter,
|
||||
} from '@/lib/execution/manual-cancellation'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import {
|
||||
@@ -845,6 +849,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const encoder = new TextEncoder()
|
||||
const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync)
|
||||
let isStreamClosed = false
|
||||
let isManualAbortRegistered = false
|
||||
|
||||
const eventWriter = createExecutionEventWriter(executionId)
|
||||
setExecutionMeta(executionId, {
|
||||
@@ -857,6 +862,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
async start(controller) {
|
||||
let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null
|
||||
|
||||
registerManualExecutionAborter(executionId, timeoutController.abort)
|
||||
isManualAbortRegistered = true
|
||||
|
||||
const sendEvent = (event: ExecutionEvent) => {
|
||||
if (!isStreamClosed) {
|
||||
try {
|
||||
@@ -1224,6 +1232,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
})
|
||||
finalMetaStatus = 'error'
|
||||
} finally {
|
||||
if (isManualAbortRegistered) {
|
||||
unregisterManualExecutionAborter(executionId)
|
||||
isManualAbortRegistered = false
|
||||
}
|
||||
try {
|
||||
await eventWriter.close()
|
||||
} catch (closeError) {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockCheckHybridAuth = vi.fn()
|
||||
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
|
||||
const mockMarkExecutionCancelled = vi.fn()
|
||||
const mockAbortManualExecution = vi.fn()
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkHybridAuth: (...args: unknown[]) => mockCheckHybridAuth(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/cancellation', () => ({
|
||||
markExecutionCancelled: (...args: unknown[]) => mockMarkExecutionCancelled(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/manual-cancellation', () => ({
|
||||
abortManualExecution: (...args: unknown[]) => mockAbortManualExecution(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
authorizeWorkflowByWorkspacePermission: (params: unknown) =>
|
||||
mockAuthorizeWorkflowByWorkspacePermission(params),
|
||||
}))
|
||||
|
||||
import { POST } from './route'
|
||||
|
||||
describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-1' })
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true })
|
||||
mockAbortManualExecution.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('returns success when cancellation was durably recorded', async () => {
|
||||
mockMarkExecutionCancelled.mockResolvedValue({
|
||||
durablyRecorded: true,
|
||||
reason: 'recorded',
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
|
||||
method: 'POST',
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: true,
|
||||
executionId: 'ex-1',
|
||||
redisAvailable: true,
|
||||
durablyRecorded: true,
|
||||
locallyAborted: false,
|
||||
reason: 'recorded',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns unsuccessful response when Redis is unavailable', async () => {
|
||||
mockMarkExecutionCancelled.mockResolvedValue({
|
||||
durablyRecorded: false,
|
||||
reason: 'redis_unavailable',
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
|
||||
method: 'POST',
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: false,
|
||||
executionId: 'ex-1',
|
||||
redisAvailable: false,
|
||||
durablyRecorded: false,
|
||||
locallyAborted: false,
|
||||
reason: 'redis_unavailable',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns unsuccessful response when Redis persistence fails', async () => {
|
||||
mockMarkExecutionCancelled.mockResolvedValue({
|
||||
durablyRecorded: false,
|
||||
reason: 'redis_write_failed',
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
|
||||
method: 'POST',
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: false,
|
||||
executionId: 'ex-1',
|
||||
redisAvailable: true,
|
||||
durablyRecorded: false,
|
||||
locallyAborted: false,
|
||||
reason: 'redis_write_failed',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns success when local fallback aborts execution without Redis durability', async () => {
|
||||
mockMarkExecutionCancelled.mockResolvedValue({
|
||||
durablyRecorded: false,
|
||||
reason: 'redis_unavailable',
|
||||
})
|
||||
mockAbortManualExecution.mockReturnValue(true)
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
|
||||
method: 'POST',
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
|
||||
}
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: true,
|
||||
executionId: 'ex-1',
|
||||
redisAvailable: false,
|
||||
durablyRecorded: false,
|
||||
locallyAborted: true,
|
||||
reason: 'redis_unavailable',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('CancelExecutionAPI')
|
||||
@@ -45,20 +46,27 @@ export async function POST(
|
||||
|
||||
logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId })
|
||||
|
||||
const marked = await markExecutionCancelled(executionId)
|
||||
const cancellation = await markExecutionCancelled(executionId)
|
||||
const locallyAborted = abortManualExecution(executionId)
|
||||
|
||||
if (marked) {
|
||||
if (cancellation.durablyRecorded) {
|
||||
logger.info('Execution marked as cancelled in Redis', { executionId })
|
||||
} else if (locallyAborted) {
|
||||
logger.info('Execution cancelled via local in-process fallback', { executionId })
|
||||
} else {
|
||||
logger.info('Redis not available, cancellation will rely on connection close', {
|
||||
logger.warn('Execution cancellation was not durably recorded', {
|
||||
executionId,
|
||||
reason: cancellation.reason,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
success: cancellation.durablyRecorded || locallyAborted,
|
||||
executionId,
|
||||
redisAvailable: marked,
|
||||
redisAvailable: cancellation.reason !== 'redis_unavailable',
|
||||
durablyRecorded: cancellation.durablyRecorded,
|
||||
locallyAborted,
|
||||
reason: cancellation.reason,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to cancel execution', { workflowId, executionId, error: error.message })
|
||||
|
||||
@@ -87,32 +87,33 @@ 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 || 'untitled'
|
||||
|
||||
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 +123,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,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
|
||||
export function ChatLoadingState() {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
|
||||
export function FormLoadingState() {
|
||||
|
||||
@@ -114,6 +114,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
if (isCollapsed) {
|
||||
document.documentElement.style.setProperty('--sidebar-width', '51px');
|
||||
document.documentElement.setAttribute('data-sidebar-collapsed', '');
|
||||
} else {
|
||||
var width = state && state.sidebarWidth;
|
||||
var maxSidebarWidth = window.innerWidth * 0.3;
|
||||
|
||||
@@ -27,8 +27,8 @@ import {
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Skeleton,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { VerifiedBadge } from '@/components/ui/verified-badge'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo } from 'react'
|
||||
import { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Square } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
@@ -51,7 +51,11 @@ interface ResourceContentProps {
|
||||
* Handles table, file, and workflow resource types with appropriate
|
||||
* embedded rendering for each.
|
||||
*/
|
||||
export function ResourceContent({ workspaceId, resource, previewMode }: ResourceContentProps) {
|
||||
export const ResourceContent = memo(function ResourceContent({
|
||||
workspaceId,
|
||||
resource,
|
||||
previewMode,
|
||||
}: ResourceContentProps) {
|
||||
switch (resource.type) {
|
||||
case 'table':
|
||||
return <Table key={resource.id} workspaceId={workspaceId} tableId={resource.id} embedded />
|
||||
@@ -84,7 +88,7 @@ export function ResourceContent({ workspaceId, resource, previewMode }: Resource
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
interface ResourceActionsProps {
|
||||
workspaceId: string
|
||||
@@ -303,10 +307,12 @@ interface EmbeddedWorkflowProps {
|
||||
|
||||
function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) {
|
||||
const workflowExists = useWorkflowRegistry((state) => Boolean(state.workflows[workflowId]))
|
||||
const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
|
||||
const hydrationWorkflowId = useWorkflowRegistry((state) => state.hydration.workflowId)
|
||||
const isMetadataLoaded = hydrationPhase !== 'idle' && hydrationPhase !== 'metadata-loading'
|
||||
const hasLoadError = hydrationPhase === 'error' && hydrationWorkflowId === workflowId
|
||||
const isMetadataLoaded = useWorkflowRegistry(
|
||||
(state) => state.hydration.phase !== 'idle' && state.hydration.phase !== 'metadata-loading'
|
||||
)
|
||||
const hasLoadError = useWorkflowRegistry(
|
||||
(state) => state.hydration.phase === 'error' && state.hydration.workflowId === workflowId
|
||||
)
|
||||
|
||||
if (!isMetadataLoaded) return LOADING_SKELETON
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
|
||||
@@ -34,7 +34,7 @@ interface MothershipViewProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MothershipView({
|
||||
export const MothershipView = memo(function MothershipView({
|
||||
workspaceId,
|
||||
chatId,
|
||||
resources,
|
||||
@@ -91,18 +91,12 @@ 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>
|
||||
</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,15 +185,34 @@ 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'
|
||||
|
||||
const files = useFileAttachments({
|
||||
userId: userId || session?.user?.id,
|
||||
workspaceId,
|
||||
disabled: false,
|
||||
isLoading: isSending,
|
||||
})
|
||||
@@ -393,9 +421,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 +487,7 @@ export function UserInput({
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSubmit, isSending, mentionTokensWithContext, value, textareaRef]
|
||||
[handleSubmit, mentionTokensWithContext, value, textareaRef]
|
||||
)
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
@@ -637,7 +663,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 +690,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 +846,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'
|
||||
@@ -16,17 +16,18 @@ import {
|
||||
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
|
||||
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import {
|
||||
MessageContent,
|
||||
MothershipView,
|
||||
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')
|
||||
|
||||
@@ -166,6 +167,8 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
|
||||
const handleResourceEvent = useCallback(() => {
|
||||
if (isResourceCollapsedRef.current) {
|
||||
const { isCollapsed, toggleCollapsed } = useSidebarStore.getState()
|
||||
if (!isCollapsed) toggleCollapsed()
|
||||
setIsResourceCollapsed(false)
|
||||
setIsResourceAnimatingIn(true)
|
||||
}
|
||||
@@ -183,8 +186,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 +296,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
|
||||
@@ -406,6 +443,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 +456,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: [],
|
||||
})
|
||||
@@ -554,8 +582,7 @@ export function useChat(
|
||||
readArgs?.path as string | undefined,
|
||||
tc.result.output
|
||||
)
|
||||
if (resource) {
|
||||
addResource(resource)
|
||||
if (resource && addResource(resource)) {
|
||||
onResourceEventRef.current?.()
|
||||
}
|
||||
}
|
||||
@@ -566,12 +593,21 @@ export function useChat(
|
||||
case 'resource_added': {
|
||||
const resource = parsed.resource
|
||||
if (resource?.type && resource?.id) {
|
||||
addResource(resource)
|
||||
const wasAdded = addResource(resource)
|
||||
invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id)
|
||||
|
||||
if (!wasAdded && activeResourceIdRef.current !== resource.id) {
|
||||
setActiveResourceId(resource.id)
|
||||
}
|
||||
onResourceEventRef.current?.()
|
||||
|
||||
if (resource.type === 'workflow') {
|
||||
if (ensureWorkflowInRegistry(resource.id, resource.title, workspaceId)) {
|
||||
const wasRegistered = ensureWorkflowInRegistry(
|
||||
resource.id,
|
||||
resource.title,
|
||||
workspaceId
|
||||
)
|
||||
if (wasAdded && wasRegistered) {
|
||||
useWorkflowRegistry.getState().setActiveWorkflow(resource.id)
|
||||
} else {
|
||||
useWorkflowRegistry.getState().loadWorkflowState(resource.id)
|
||||
@@ -619,7 +655,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 +727,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 +772,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 +808,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
|
||||
|
||||
@@ -819,12 +888,15 @@ export function useChat(
|
||||
try {
|
||||
const currentActiveId = activeResourceIdRef.current
|
||||
const currentResources = resourcesRef.current
|
||||
const activeRes = currentActiveId
|
||||
? currentResources.find((r) => r.id === currentActiveId)
|
||||
: undefined
|
||||
const resourceAttachments = activeRes
|
||||
? [{ type: activeRes.type, id: activeRes.id }]
|
||||
: undefined
|
||||
const resourceAttachments =
|
||||
currentResources.length > 0
|
||||
? currentResources.map((r) => ({
|
||||
type: r.type,
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
active: r.id === currentActiveId,
|
||||
}))
|
||||
: undefined
|
||||
|
||||
const response = await fetch(MOTHERSHIP_CHAT_API_PATH, {
|
||||
method: 'POST',
|
||||
@@ -854,14 +926,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 +1021,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 +1072,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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -23,9 +23,9 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
ModalTabsContent,
|
||||
ModalTabsList,
|
||||
ModalTabsTrigger,
|
||||
Skeleton,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
import type { ConnectorConfig } from '@/connectors/types'
|
||||
import type { ConnectorData } from '@/hooks/queries/kb/connectors'
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import type { DashboardStatsResponse, WorkflowStats } from '@/hooks/queries/logs'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
@@ -396,7 +395,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<ScrollArea className='mt-[20px] h-full w-full overflow-y-auto' ref={scrollAreaRef}>
|
||||
<div className='mt-[20px] h-full w-full overflow-y-auto' ref={scrollAreaRef}>
|
||||
<div className='flex flex-col gap-[10px] pb-[16px]'>
|
||||
{/* Timestamp & Workflow Row */}
|
||||
<div className='flex min-w-0 items-center gap-[16px] px-[1px]'>
|
||||
@@ -632,7 +631,7 @@ export const LogDetails = memo(function LogDetails({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Badge, Combobox, type ComboboxOption, Label } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { Badge, Combobox, type ComboboxOption, Label, Skeleton } from '@/components/emcn'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
|
||||
interface WorkflowSelectorProps {
|
||||
|
||||
@@ -18,11 +18,11 @@ import {
|
||||
ModalTabsContent,
|
||||
ModalTabsList,
|
||||
ModalTabsTrigger,
|
||||
Skeleton,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
} from '@/components/emcn'
|
||||
import { SlackIcon } from '@/components/icons'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { formatDate } from '@/lib/core/utils/formatting'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
|
||||
@@ -17,11 +17,12 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
} from '@/components/emcn'
|
||||
import { GmailIcon, OutlookIcon } from '@/components/icons'
|
||||
import { Input as BaseInput, Skeleton } from '@/components/ui'
|
||||
import { Input as BaseInput } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getSubscriptionStatus } from '@/lib/billing/client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
@@ -17,11 +17,12 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import {
|
||||
clearPendingCredentialCreateRequest,
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton, Input as UiInput } from '@/components/ui'
|
||||
import { Input as UiInput } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import {
|
||||
clearPendingCredentialCreateRequest,
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { CreditBalance } from './credit-balance'
|
||||
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
|
||||
export { ReferralCode } from './referral-code'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { ReferralCode } from './referral-code'
|
||||
@@ -1,82 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button, Input, Label } from '@/components/emcn'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { useRedeemReferralCode } from '@/hooks/queries/subscription'
|
||||
|
||||
interface ReferralCodeProps {
|
||||
onRedeemComplete?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline referral/promo code entry field with redeem button.
|
||||
* One-time use per account — shows success or "already redeemed" state.
|
||||
*/
|
||||
export function ReferralCode({ onRedeemComplete }: ReferralCodeProps) {
|
||||
const [code, setCode] = useState('')
|
||||
const redeemCode = useRedeemReferralCode()
|
||||
|
||||
const handleRedeem = () => {
|
||||
const trimmed = code.trim()
|
||||
if (!trimmed || redeemCode.isPending) return
|
||||
|
||||
redeemCode.mutate(
|
||||
{ code: trimmed },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setCode('')
|
||||
onRedeemComplete?.()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (redeemCode.isSuccess) {
|
||||
return (
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label>Referral Code</Label>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||
+{dollarsToCredits(redeemCode.data.bonusAmount ?? 0).toLocaleString()} credits applied
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<Label className='shrink-0'>Referral Code</Label>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Input
|
||||
type='text'
|
||||
value={code}
|
||||
onChange={(e) => {
|
||||
setCode(e.target.value)
|
||||
redeemCode.reset()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRedeem()
|
||||
}}
|
||||
placeholder='Enter code'
|
||||
className='h-[32px] w-[140px] bg-[var(--surface-4)] text-[13px]'
|
||||
disabled={redeemCode.isPending}
|
||||
/>
|
||||
<Button
|
||||
variant='default'
|
||||
className='h-[32px] shrink-0 text-[13px]'
|
||||
onClick={handleRedeem}
|
||||
disabled={redeemCode.isPending || !code.trim()}
|
||||
>
|
||||
{redeemCode.isPending ? 'Redeeming...' : 'Redeem'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{redeemCode.error && (
|
||||
<span className='text-right text-[11px] text-[var(--text-error)]'>
|
||||
{redeemCode.error.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,10 +14,10 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession, useSubscription } from '@/lib/auth/auth-client'
|
||||
import { USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
|
||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||
@@ -47,7 +47,6 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
|
||||
import {
|
||||
CreditBalance,
|
||||
PlanCard,
|
||||
ReferralCode,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/subscription/components'
|
||||
import {
|
||||
ENTERPRISE_PLAN_FEATURES,
|
||||
@@ -1000,11 +999,6 @@ export function Subscription() {
|
||||
inlineButton
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Referral Code */}
|
||||
{!subscription.isEnterprise && (
|
||||
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Badge, Button } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge, Button, Skeleton } from '@/components/emcn'
|
||||
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { TagItem } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { Skeleton, type TagItem } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getPlanTierCredits, getPlanTierDollars } from '@/lib/billing/plan-helpers'
|
||||
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
|
||||
|
||||
@@ -4,9 +4,8 @@ import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Camera, Globe, Linkedin, Mail } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Button, Combobox, Input, Textarea } from '@/components/emcn'
|
||||
import { Button, Combobox, Input, Skeleton, Textarea } from '@/components/emcn'
|
||||
import { AgentIcon, xIcon as XIcon } from '@/components/icons'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import type { CreatorProfileDetails } from '@/app/_types/creator-profile'
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
SModalTabs,
|
||||
SModalTabsBody,
|
||||
SModalTabsContent,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useApiKeys } from '@/hooks/queries/api-keys'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface MessageFileAttachment {
|
||||
|
||||
interface UseFileAttachmentsProps {
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
disabled?: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
@@ -55,7 +56,7 @@ interface UseFileAttachmentsProps {
|
||||
* @returns File attachment state and operations
|
||||
*/
|
||||
export function useFileAttachments(props: UseFileAttachmentsProps) {
|
||||
const { userId, disabled, isLoading } = props
|
||||
const { userId, workspaceId, disabled, isLoading } = props
|
||||
|
||||
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([])
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
@@ -135,7 +136,10 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('context', 'copilot')
|
||||
formData.append('context', 'mothership')
|
||||
if (workspaceId) {
|
||||
formData.append('workspaceId', workspaceId)
|
||||
}
|
||||
|
||||
const uploadResponse = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
@@ -171,7 +175,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[userId]
|
||||
[userId, workspaceId]
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -188,6 +188,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
const fileAttachments = useFileAttachments({
|
||||
userId: session?.user?.id,
|
||||
workspaceId,
|
||||
disabled,
|
||||
isLoading,
|
||||
})
|
||||
|
||||
@@ -12,11 +12,11 @@ import {
|
||||
Code,
|
||||
Input,
|
||||
Label,
|
||||
Skeleton,
|
||||
TagInput,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import type { AgentAuthentication, AgentCapabilities } from '@/lib/a2a/types'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
Code,
|
||||
Combobox,
|
||||
Label,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
|
||||
|
||||
interface WorkflowDeploymentInfo {
|
||||
|
||||
@@ -14,12 +14,13 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Alert, AlertDescription, Skeleton } from '@/components/ui'
|
||||
import { Alert, AlertDescription } from '@/components/ui'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { generatePassword } from '@/lib/core/security/encryption'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
ButtonGroupItem,
|
||||
Input,
|
||||
Label,
|
||||
Skeleton,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { formatDateTime } from '@/lib/core/utils/formatting'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||
import { useUpdateDeploymentVersion } from '@/hooks/queries/deployments'
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||
import { Preview, PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
type ComboboxOption,
|
||||
Input,
|
||||
Label,
|
||||
Skeleton,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
TagInput,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,11 +4,6 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { processStreamingBlockLogs } from '@/lib/tokenization'
|
||||
import type {
|
||||
BlockCompletedData,
|
||||
BlockErrorData,
|
||||
BlockStartedData,
|
||||
} from '@/lib/workflows/executor/execution-events'
|
||||
import {
|
||||
extractTriggerMockPayload,
|
||||
selectBestTrigger,
|
||||
@@ -21,21 +16,14 @@ import {
|
||||
} from '@/lib/workflows/triggers/triggers'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
import {
|
||||
markOutgoingEdgesFromOutput,
|
||||
updateActiveBlockRefCount,
|
||||
type BlockEventHandlerConfig,
|
||||
createBlockEventHandlers,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { SerializableExecutionState } from '@/executor/execution/types'
|
||||
import type {
|
||||
BlockLog,
|
||||
BlockState,
|
||||
ExecutionResult,
|
||||
NormalizedBlockOutput,
|
||||
StreamingExecution,
|
||||
} from '@/executor/types'
|
||||
import type { BlockLog, BlockState, ExecutionResult, StreamingExecution } from '@/executor/types'
|
||||
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||
import { coerceValue } from '@/executor/utils/start-block'
|
||||
import { stripCloneSuffixes } from '@/executor/utils/subflow-utils'
|
||||
import { subscriptionKeys } from '@/hooks/queries/subscription'
|
||||
import { useExecutionStream } from '@/hooks/use-execution-stream'
|
||||
import { WorkflowValidationError } from '@/serializer'
|
||||
@@ -63,20 +51,6 @@ interface DebugValidationResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface BlockEventHandlerConfig {
|
||||
workflowId?: string
|
||||
executionIdRef: { current: string }
|
||||
workflowEdges: Array<{ id: string; source: string; target: string; sourceHandle?: string | null }>
|
||||
activeBlocksSet: Set<string>
|
||||
activeBlockRefCounts: Map<string, number>
|
||||
accumulatedBlockLogs: BlockLog[]
|
||||
accumulatedBlockStates: Map<string, BlockState>
|
||||
executedBlockIds: Set<string>
|
||||
consoleMode: 'update' | 'add'
|
||||
includeStartConsoleEntry: boolean
|
||||
onBlockCompleteCallback?: (blockId: string, output: unknown) => Promise<void>
|
||||
}
|
||||
|
||||
const WORKFLOW_EXECUTION_FAILURE_MESSAGE = 'Workflow execution failed'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -309,279 +283,15 @@ export function useWorkflowExecution() {
|
||||
)
|
||||
|
||||
const buildBlockEventHandlers = useCallback(
|
||||
(config: BlockEventHandlerConfig) => {
|
||||
const {
|
||||
workflowId,
|
||||
executionIdRef,
|
||||
workflowEdges,
|
||||
activeBlocksSet,
|
||||
activeBlockRefCounts,
|
||||
accumulatedBlockLogs,
|
||||
accumulatedBlockStates,
|
||||
executedBlockIds,
|
||||
consoleMode,
|
||||
includeStartConsoleEntry,
|
||||
onBlockCompleteCallback,
|
||||
} = config
|
||||
|
||||
/** Returns true if this execution was cancelled or superseded by another run. */
|
||||
const isStaleExecution = () =>
|
||||
!!(
|
||||
workflowId &&
|
||||
executionIdRef.current &&
|
||||
useExecutionStore.getState().getCurrentExecutionId(workflowId) !== executionIdRef.current
|
||||
)
|
||||
|
||||
const updateActiveBlocks = (blockId: string, isActive: boolean) => {
|
||||
if (!workflowId) return
|
||||
updateActiveBlockRefCount(activeBlockRefCounts, activeBlocksSet, blockId, isActive)
|
||||
setActiveBlocks(workflowId, new Set(activeBlocksSet))
|
||||
}
|
||||
|
||||
const markOutgoingEdges = (blockId: string, output: Record<string, any> | undefined) => {
|
||||
if (!workflowId) return
|
||||
markOutgoingEdgesFromOutput(blockId, output, workflowEdges, workflowId, setEdgeRunStatus)
|
||||
}
|
||||
|
||||
const isContainerBlockType = (blockType?: string) => {
|
||||
return blockType === 'loop' || blockType === 'parallel'
|
||||
}
|
||||
|
||||
/** Extracts iteration and child-workflow fields shared across console entry call sites. */
|
||||
const extractIterationFields = (
|
||||
data: BlockStartedData | BlockCompletedData | BlockErrorData
|
||||
) => ({
|
||||
iterationCurrent: data.iterationCurrent,
|
||||
iterationTotal: data.iterationTotal,
|
||||
iterationType: data.iterationType,
|
||||
iterationContainerId: data.iterationContainerId,
|
||||
parentIterations: data.parentIterations,
|
||||
childWorkflowBlockId: data.childWorkflowBlockId,
|
||||
childWorkflowName: data.childWorkflowName,
|
||||
...('childWorkflowInstanceId' in data && {
|
||||
childWorkflowInstanceId: data.childWorkflowInstanceId,
|
||||
}),
|
||||
})
|
||||
|
||||
const createBlockLogEntry = (
|
||||
data: BlockCompletedData | BlockErrorData,
|
||||
options: { success: boolean; output?: unknown; error?: string }
|
||||
): BlockLog => ({
|
||||
blockId: data.blockId,
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
blockType: data.blockType || 'unknown',
|
||||
input: data.input || {},
|
||||
output: options.output ?? {},
|
||||
success: options.success,
|
||||
error: options.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt: data.startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt: data.endedAt,
|
||||
})
|
||||
|
||||
const addConsoleEntry = (data: BlockCompletedData, output: NormalizedBlockOutput) => {
|
||||
if (!workflowId) return
|
||||
addConsole({
|
||||
input: data.input || {},
|
||||
output,
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt: data.startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt: data.endedAt,
|
||||
workflowId,
|
||||
blockId: data.blockId,
|
||||
executionId: executionIdRef.current,
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
blockType: data.blockType || 'unknown',
|
||||
...extractIterationFields(data),
|
||||
})
|
||||
}
|
||||
|
||||
const addConsoleErrorEntry = (data: BlockErrorData) => {
|
||||
if (!workflowId) return
|
||||
addConsole({
|
||||
input: data.input || {},
|
||||
output: {},
|
||||
success: false,
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt: data.startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt: data.endedAt,
|
||||
workflowId,
|
||||
blockId: data.blockId,
|
||||
executionId: executionIdRef.current,
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
blockType: data.blockType || 'unknown',
|
||||
...extractIterationFields(data),
|
||||
})
|
||||
}
|
||||
|
||||
const updateConsoleEntry = (data: BlockCompletedData) => {
|
||||
updateConsole(
|
||||
data.blockId,
|
||||
{
|
||||
executionOrder: data.executionOrder,
|
||||
input: data.input || {},
|
||||
replaceOutput: data.output,
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt: data.startedAt,
|
||||
endedAt: data.endedAt,
|
||||
isRunning: false,
|
||||
...extractIterationFields(data),
|
||||
},
|
||||
executionIdRef.current
|
||||
)
|
||||
}
|
||||
|
||||
const updateConsoleErrorEntry = (data: BlockErrorData) => {
|
||||
updateConsole(
|
||||
data.blockId,
|
||||
{
|
||||
executionOrder: data.executionOrder,
|
||||
input: data.input || {},
|
||||
replaceOutput: {},
|
||||
success: false,
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt: data.startedAt,
|
||||
endedAt: data.endedAt,
|
||||
isRunning: false,
|
||||
...extractIterationFields(data),
|
||||
},
|
||||
executionIdRef.current
|
||||
)
|
||||
}
|
||||
|
||||
const onBlockStarted = (data: BlockStartedData) => {
|
||||
if (isStaleExecution()) return
|
||||
updateActiveBlocks(data.blockId, true)
|
||||
|
||||
if (!includeStartConsoleEntry || !workflowId) return
|
||||
|
||||
const startedAt = new Date().toISOString()
|
||||
addConsole({
|
||||
input: {},
|
||||
output: undefined,
|
||||
success: undefined,
|
||||
durationMs: undefined,
|
||||
startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt: undefined,
|
||||
workflowId,
|
||||
blockId: data.blockId,
|
||||
executionId: executionIdRef.current,
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
blockType: data.blockType || 'unknown',
|
||||
isRunning: true,
|
||||
...extractIterationFields(data),
|
||||
})
|
||||
}
|
||||
|
||||
const onBlockCompleted = (data: BlockCompletedData) => {
|
||||
if (isStaleExecution()) return
|
||||
updateActiveBlocks(data.blockId, false)
|
||||
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'success')
|
||||
markOutgoingEdges(data.blockId, data.output as Record<string, any> | undefined)
|
||||
executedBlockIds.add(data.blockId)
|
||||
accumulatedBlockStates.set(data.blockId, {
|
||||
output: data.output,
|
||||
executed: true,
|
||||
executionTime: data.durationMs,
|
||||
})
|
||||
|
||||
// For nested containers, the SSE blockId may be a cloned ID (e.g. P1__obranch-0).
|
||||
// Also record the original workflow-level ID so the canvas can highlight it.
|
||||
if (isContainerBlockType(data.blockType)) {
|
||||
const originalId = stripCloneSuffixes(data.blockId)
|
||||
if (originalId !== data.blockId) {
|
||||
executedBlockIds.add(originalId)
|
||||
if (workflowId) setBlockRunStatus(workflowId, originalId, 'success')
|
||||
}
|
||||
}
|
||||
|
||||
if (isContainerBlockType(data.blockType) && !data.iterationContainerId) {
|
||||
const output = data.output as Record<string, any> | undefined
|
||||
const isEmptySubflow = Array.isArray(output?.results) && output.results.length === 0
|
||||
if (!isEmptySubflow) return
|
||||
}
|
||||
|
||||
accumulatedBlockLogs.push(createBlockLogEntry(data, { success: true, output: data.output }))
|
||||
|
||||
if (consoleMode === 'update') {
|
||||
updateConsoleEntry(data)
|
||||
} else {
|
||||
addConsoleEntry(data, data.output as NormalizedBlockOutput)
|
||||
}
|
||||
|
||||
if (onBlockCompleteCallback) {
|
||||
onBlockCompleteCallback(data.blockId, data.output).catch((error) => {
|
||||
logger.error('Error in onBlockComplete callback:', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onBlockError = (data: BlockErrorData) => {
|
||||
if (isStaleExecution()) return
|
||||
updateActiveBlocks(data.blockId, false)
|
||||
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'error')
|
||||
markOutgoingEdges(data.blockId, { error: data.error })
|
||||
|
||||
executedBlockIds.add(data.blockId)
|
||||
accumulatedBlockStates.set(data.blockId, {
|
||||
output: { error: data.error },
|
||||
executed: true,
|
||||
executionTime: data.durationMs || 0,
|
||||
})
|
||||
|
||||
// For nested containers, also record the original workflow-level ID
|
||||
if (isContainerBlockType(data.blockType)) {
|
||||
const originalId = stripCloneSuffixes(data.blockId)
|
||||
if (originalId !== data.blockId) {
|
||||
executedBlockIds.add(originalId)
|
||||
if (workflowId) setBlockRunStatus(workflowId, originalId, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
accumulatedBlockLogs.push(
|
||||
createBlockLogEntry(data, { success: false, output: {}, error: data.error })
|
||||
)
|
||||
|
||||
if (consoleMode === 'update') {
|
||||
updateConsoleErrorEntry(data)
|
||||
} else {
|
||||
addConsoleErrorEntry(data)
|
||||
}
|
||||
}
|
||||
|
||||
const onBlockChildWorkflowStarted = (data: {
|
||||
blockId: string
|
||||
childWorkflowInstanceId: string
|
||||
iterationCurrent?: number
|
||||
iterationContainerId?: string
|
||||
executionOrder?: number
|
||||
}) => {
|
||||
if (isStaleExecution()) return
|
||||
updateConsole(
|
||||
data.blockId,
|
||||
{
|
||||
childWorkflowInstanceId: data.childWorkflowInstanceId,
|
||||
...(data.iterationCurrent !== undefined && { iterationCurrent: data.iterationCurrent }),
|
||||
...(data.iterationContainerId !== undefined && {
|
||||
iterationContainerId: data.iterationContainerId,
|
||||
}),
|
||||
...(data.executionOrder !== undefined && { executionOrder: data.executionOrder }),
|
||||
},
|
||||
executionIdRef.current
|
||||
)
|
||||
}
|
||||
|
||||
return { onBlockStarted, onBlockCompleted, onBlockError, onBlockChildWorkflowStarted }
|
||||
},
|
||||
[addConsole, setActiveBlocks, setBlockRunStatus, setEdgeRunStatus, updateConsole]
|
||||
(config: BlockEventHandlerConfig) =>
|
||||
createBlockEventHandlers(config, {
|
||||
addConsole,
|
||||
updateConsole,
|
||||
setActiveBlocks,
|
||||
setBlockRunStatus,
|
||||
setEdgeRunStatus,
|
||||
}),
|
||||
[addConsole, updateConsole, setActiveBlocks, setBlockRunStatus, setEdgeRunStatus]
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import type { ExecutionResult, StreamingExecution } from '@/executor/types'
|
||||
import type {
|
||||
BlockCompletedData,
|
||||
BlockErrorData,
|
||||
BlockStartedData,
|
||||
} from '@/lib/workflows/executor/execution-events'
|
||||
import type {
|
||||
BlockLog,
|
||||
BlockState,
|
||||
ExecutionResult,
|
||||
NormalizedBlockOutput,
|
||||
StreamingExecution,
|
||||
} from '@/executor/types'
|
||||
import { stripCloneSuffixes } from '@/executor/utils/subflow-utils'
|
||||
|
||||
const logger = createLogger('workflow-execution-utils')
|
||||
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import type { ConsoleEntry, ConsoleUpdate } from '@/stores/terminal'
|
||||
import { useTerminalConsoleStore } from '@/stores/terminal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -85,6 +102,310 @@ export function markOutgoingEdgesFromOutput(
|
||||
}
|
||||
}
|
||||
|
||||
export interface BlockEventHandlerConfig {
|
||||
workflowId?: string
|
||||
executionIdRef: { current: string }
|
||||
workflowEdges: Array<{ id: string; source: string; target: string; sourceHandle?: string | null }>
|
||||
activeBlocksSet: Set<string>
|
||||
activeBlockRefCounts: Map<string, number>
|
||||
accumulatedBlockLogs: BlockLog[]
|
||||
accumulatedBlockStates: Map<string, BlockState>
|
||||
executedBlockIds: Set<string>
|
||||
consoleMode: 'update' | 'add'
|
||||
includeStartConsoleEntry: boolean
|
||||
onBlockCompleteCallback?: (blockId: string, output: unknown) => Promise<void>
|
||||
}
|
||||
|
||||
export interface BlockEventHandlerDeps {
|
||||
addConsole: (entry: Omit<ConsoleEntry, 'id' | 'timestamp'>) => ConsoleEntry
|
||||
updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => void
|
||||
setActiveBlocks: (workflowId: string, blocks: Set<string>) => void
|
||||
setBlockRunStatus: (workflowId: string, blockId: string, status: 'success' | 'error') => void
|
||||
setEdgeRunStatus: (workflowId: string, edgeId: string, status: 'success' | 'error') => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates block event handlers for SSE execution events.
|
||||
* Shared by the workflow execution hook and standalone execution utilities.
|
||||
*/
|
||||
export function createBlockEventHandlers(
|
||||
config: BlockEventHandlerConfig,
|
||||
deps: BlockEventHandlerDeps
|
||||
) {
|
||||
const {
|
||||
workflowId,
|
||||
executionIdRef,
|
||||
workflowEdges,
|
||||
activeBlocksSet,
|
||||
activeBlockRefCounts,
|
||||
accumulatedBlockLogs,
|
||||
accumulatedBlockStates,
|
||||
executedBlockIds,
|
||||
consoleMode,
|
||||
includeStartConsoleEntry,
|
||||
onBlockCompleteCallback,
|
||||
} = config
|
||||
|
||||
const { addConsole, updateConsole, setActiveBlocks, setBlockRunStatus, setEdgeRunStatus } = deps
|
||||
|
||||
const isStaleExecution = () =>
|
||||
!!(
|
||||
workflowId &&
|
||||
executionIdRef.current &&
|
||||
useExecutionStore.getState().getCurrentExecutionId(workflowId) !== executionIdRef.current
|
||||
)
|
||||
|
||||
const updateActiveBlocks = (blockId: string, isActive: boolean) => {
|
||||
if (!workflowId) return
|
||||
updateActiveBlockRefCount(activeBlockRefCounts, activeBlocksSet, blockId, isActive)
|
||||
setActiveBlocks(workflowId, new Set(activeBlocksSet))
|
||||
}
|
||||
|
||||
const markOutgoingEdges = (blockId: string, output: Record<string, any> | undefined) => {
|
||||
if (!workflowId) return
|
||||
markOutgoingEdgesFromOutput(blockId, output, workflowEdges, workflowId, setEdgeRunStatus)
|
||||
}
|
||||
|
||||
const isContainerBlockType = (blockType?: string) => {
|
||||
return blockType === 'loop' || blockType === 'parallel'
|
||||
}
|
||||
|
||||
const extractIterationFields = (
|
||||
data: BlockStartedData | BlockCompletedData | BlockErrorData
|
||||
) => ({
|
||||
iterationCurrent: data.iterationCurrent,
|
||||
iterationTotal: data.iterationTotal,
|
||||
iterationType: data.iterationType,
|
||||
iterationContainerId: data.iterationContainerId,
|
||||
parentIterations: data.parentIterations,
|
||||
childWorkflowBlockId: data.childWorkflowBlockId,
|
||||
childWorkflowName: data.childWorkflowName,
|
||||
...('childWorkflowInstanceId' in data && {
|
||||
childWorkflowInstanceId: data.childWorkflowInstanceId,
|
||||
}),
|
||||
})
|
||||
|
||||
const createBlockLogEntry = (
|
||||
data: BlockCompletedData | BlockErrorData,
|
||||
options: { success: boolean; output?: unknown; error?: string }
|
||||
): BlockLog => ({
|
||||
blockId: data.blockId,
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
blockType: data.blockType || 'unknown',
|
||||
input: data.input || {},
|
||||
output: options.output ?? {},
|
||||
success: options.success,
|
||||
error: options.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt: data.startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt: data.endedAt,
|
||||
})
|
||||
|
||||
const addConsoleEntry = (data: BlockCompletedData, output: NormalizedBlockOutput) => {
|
||||
if (!workflowId) return
|
||||
addConsole({
|
||||
input: data.input || {},
|
||||
output,
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt: data.startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt: data.endedAt,
|
||||
workflowId,
|
||||
blockId: data.blockId,
|
||||
executionId: executionIdRef.current,
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
blockType: data.blockType || 'unknown',
|
||||
...extractIterationFields(data),
|
||||
})
|
||||
}
|
||||
|
||||
const addConsoleErrorEntry = (data: BlockErrorData) => {
|
||||
if (!workflowId) return
|
||||
addConsole({
|
||||
input: data.input || {},
|
||||
output: {},
|
||||
success: false,
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt: data.startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt: data.endedAt,
|
||||
workflowId,
|
||||
blockId: data.blockId,
|
||||
executionId: executionIdRef.current,
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
blockType: data.blockType || 'unknown',
|
||||
...extractIterationFields(data),
|
||||
})
|
||||
}
|
||||
|
||||
const updateConsoleEntry = (data: BlockCompletedData) => {
|
||||
updateConsole(
|
||||
data.blockId,
|
||||
{
|
||||
executionOrder: data.executionOrder,
|
||||
input: data.input || {},
|
||||
replaceOutput: data.output,
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt: data.startedAt,
|
||||
endedAt: data.endedAt,
|
||||
isRunning: false,
|
||||
...extractIterationFields(data),
|
||||
},
|
||||
executionIdRef.current
|
||||
)
|
||||
}
|
||||
|
||||
const updateConsoleErrorEntry = (data: BlockErrorData) => {
|
||||
updateConsole(
|
||||
data.blockId,
|
||||
{
|
||||
executionOrder: data.executionOrder,
|
||||
input: data.input || {},
|
||||
replaceOutput: {},
|
||||
success: false,
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt: data.startedAt,
|
||||
endedAt: data.endedAt,
|
||||
isRunning: false,
|
||||
...extractIterationFields(data),
|
||||
},
|
||||
executionIdRef.current
|
||||
)
|
||||
}
|
||||
|
||||
const onBlockStarted = (data: BlockStartedData) => {
|
||||
if (isStaleExecution()) return
|
||||
updateActiveBlocks(data.blockId, true)
|
||||
|
||||
if (!includeStartConsoleEntry || !workflowId) return
|
||||
|
||||
const startedAt = new Date().toISOString()
|
||||
addConsole({
|
||||
input: {},
|
||||
output: undefined,
|
||||
success: undefined,
|
||||
durationMs: undefined,
|
||||
startedAt,
|
||||
executionOrder: data.executionOrder,
|
||||
endedAt: undefined,
|
||||
workflowId,
|
||||
blockId: data.blockId,
|
||||
executionId: executionIdRef.current,
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
blockType: data.blockType || 'unknown',
|
||||
isRunning: true,
|
||||
...extractIterationFields(data),
|
||||
})
|
||||
}
|
||||
|
||||
const onBlockCompleted = (data: BlockCompletedData) => {
|
||||
if (isStaleExecution()) return
|
||||
updateActiveBlocks(data.blockId, false)
|
||||
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'success')
|
||||
markOutgoingEdges(data.blockId, data.output as Record<string, any> | undefined)
|
||||
executedBlockIds.add(data.blockId)
|
||||
accumulatedBlockStates.set(data.blockId, {
|
||||
output: data.output,
|
||||
executed: true,
|
||||
executionTime: data.durationMs,
|
||||
})
|
||||
|
||||
if (isContainerBlockType(data.blockType)) {
|
||||
const originalId = stripCloneSuffixes(data.blockId)
|
||||
if (originalId !== data.blockId) {
|
||||
executedBlockIds.add(originalId)
|
||||
if (workflowId) setBlockRunStatus(workflowId, originalId, 'success')
|
||||
}
|
||||
}
|
||||
|
||||
if (isContainerBlockType(data.blockType) && !data.iterationContainerId) {
|
||||
const output = data.output as Record<string, any> | undefined
|
||||
const isEmptySubflow = Array.isArray(output?.results) && output.results.length === 0
|
||||
if (!isEmptySubflow) {
|
||||
if (includeStartConsoleEntry) {
|
||||
updateConsoleEntry(data)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
accumulatedBlockLogs.push(createBlockLogEntry(data, { success: true, output: data.output }))
|
||||
|
||||
if (consoleMode === 'update') {
|
||||
updateConsoleEntry(data)
|
||||
} else {
|
||||
addConsoleEntry(data, data.output as NormalizedBlockOutput)
|
||||
}
|
||||
|
||||
if (onBlockCompleteCallback) {
|
||||
onBlockCompleteCallback(data.blockId, data.output).catch((error) => {
|
||||
logger.error('Error in onBlockComplete callback:', { blockId: data.blockId, error })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onBlockError = (data: BlockErrorData) => {
|
||||
if (isStaleExecution()) return
|
||||
updateActiveBlocks(data.blockId, false)
|
||||
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'error')
|
||||
markOutgoingEdges(data.blockId, { error: data.error })
|
||||
|
||||
executedBlockIds.add(data.blockId)
|
||||
accumulatedBlockStates.set(data.blockId, {
|
||||
output: { error: data.error },
|
||||
executed: true,
|
||||
executionTime: data.durationMs || 0,
|
||||
})
|
||||
|
||||
if (isContainerBlockType(data.blockType)) {
|
||||
const originalId = stripCloneSuffixes(data.blockId)
|
||||
if (originalId !== data.blockId) {
|
||||
executedBlockIds.add(originalId)
|
||||
if (workflowId) setBlockRunStatus(workflowId, originalId, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
accumulatedBlockLogs.push(
|
||||
createBlockLogEntry(data, { success: false, output: {}, error: data.error })
|
||||
)
|
||||
|
||||
if (consoleMode === 'update') {
|
||||
updateConsoleErrorEntry(data)
|
||||
} else {
|
||||
addConsoleErrorEntry(data)
|
||||
}
|
||||
}
|
||||
|
||||
const onBlockChildWorkflowStarted = (data: {
|
||||
blockId: string
|
||||
childWorkflowInstanceId: string
|
||||
iterationCurrent?: number
|
||||
iterationContainerId?: string
|
||||
executionOrder?: number
|
||||
}) => {
|
||||
if (isStaleExecution()) return
|
||||
updateConsole(
|
||||
data.blockId,
|
||||
{
|
||||
childWorkflowInstanceId: data.childWorkflowInstanceId,
|
||||
...(data.iterationCurrent !== undefined && { iterationCurrent: data.iterationCurrent }),
|
||||
...(data.iterationContainerId !== undefined && {
|
||||
iterationContainerId: data.iterationContainerId,
|
||||
}),
|
||||
...(data.executionOrder !== undefined && { executionOrder: data.executionOrder }),
|
||||
},
|
||||
executionIdRef.current
|
||||
)
|
||||
}
|
||||
|
||||
return { onBlockStarted, onBlockCompleted, onBlockError, onBlockChildWorkflowStarted }
|
||||
}
|
||||
|
||||
export interface WorkflowExecutionOptions {
|
||||
workflowId?: string
|
||||
workflowInput?: any
|
||||
@@ -115,7 +436,7 @@ export async function executeWorkflowWithFullLogging(
|
||||
}
|
||||
|
||||
const executionId = options.executionId || uuidv4()
|
||||
const { addConsole } = useTerminalConsoleStore.getState()
|
||||
const { addConsole, updateConsole } = useTerminalConsoleStore.getState()
|
||||
const { setActiveBlocks, setBlockRunStatus, setEdgeRunStatus, setCurrentExecutionId } =
|
||||
useExecutionStore.getState()
|
||||
const wfId = targetWorkflowId
|
||||
@@ -123,6 +444,24 @@ export async function executeWorkflowWithFullLogging(
|
||||
|
||||
const activeBlocksSet = new Set<string>()
|
||||
const activeBlockRefCounts = new Map<string, number>()
|
||||
const executionIdRef = { current: executionId }
|
||||
|
||||
const blockHandlers = createBlockEventHandlers(
|
||||
{
|
||||
workflowId: wfId,
|
||||
executionIdRef,
|
||||
workflowEdges,
|
||||
activeBlocksSet,
|
||||
activeBlockRefCounts,
|
||||
accumulatedBlockLogs: [],
|
||||
accumulatedBlockStates: new Map(),
|
||||
executedBlockIds: new Set(),
|
||||
consoleMode: 'update',
|
||||
includeStartConsoleEntry: true,
|
||||
onBlockCompleteCallback: options.onBlockComplete,
|
||||
},
|
||||
{ addConsole, updateConsole, setActiveBlocks, setBlockRunStatus, setEdgeRunStatus }
|
||||
)
|
||||
|
||||
const payload: any = {
|
||||
input: options.workflowInput,
|
||||
@@ -188,125 +527,25 @@ export async function executeWorkflowWithFullLogging(
|
||||
switch (event.type) {
|
||||
case 'execution:started': {
|
||||
setCurrentExecutionId(wfId, event.executionId)
|
||||
break
|
||||
}
|
||||
case 'block:started': {
|
||||
updateActiveBlockRefCount(
|
||||
activeBlockRefCounts,
|
||||
activeBlocksSet,
|
||||
event.data.blockId,
|
||||
true
|
||||
)
|
||||
setActiveBlocks(wfId, new Set(activeBlocksSet))
|
||||
executionIdRef.current = event.executionId || executionId
|
||||
break
|
||||
}
|
||||
|
||||
case 'block:completed': {
|
||||
updateActiveBlockRefCount(
|
||||
activeBlockRefCounts,
|
||||
activeBlocksSet,
|
||||
event.data.blockId,
|
||||
false
|
||||
)
|
||||
setActiveBlocks(wfId, new Set(activeBlocksSet))
|
||||
|
||||
setBlockRunStatus(wfId, event.data.blockId, 'success')
|
||||
markOutgoingEdgesFromOutput(
|
||||
event.data.blockId,
|
||||
event.data.output,
|
||||
workflowEdges,
|
||||
wfId,
|
||||
setEdgeRunStatus
|
||||
)
|
||||
|
||||
addConsole({
|
||||
input: event.data.input || {},
|
||||
output: event.data.output,
|
||||
success: true,
|
||||
durationMs: event.data.durationMs,
|
||||
startedAt: new Date(Date.now() - event.data.durationMs).toISOString(),
|
||||
executionOrder: event.data.executionOrder,
|
||||
endedAt: new Date().toISOString(),
|
||||
workflowId: targetWorkflowId,
|
||||
blockId: event.data.blockId,
|
||||
executionId,
|
||||
blockName: event.data.blockName,
|
||||
blockType: event.data.blockType,
|
||||
iterationCurrent: event.data.iterationCurrent,
|
||||
iterationTotal: event.data.iterationTotal,
|
||||
iterationType: event.data.iterationType,
|
||||
iterationContainerId: event.data.iterationContainerId,
|
||||
childWorkflowBlockId: event.data.childWorkflowBlockId,
|
||||
childWorkflowName: event.data.childWorkflowName,
|
||||
childWorkflowInstanceId: event.data.childWorkflowInstanceId,
|
||||
})
|
||||
|
||||
if (options.onBlockComplete) {
|
||||
options.onBlockComplete(event.data.blockId, event.data.output).catch(() => {})
|
||||
}
|
||||
case 'block:started':
|
||||
blockHandlers.onBlockStarted(event.data)
|
||||
break
|
||||
}
|
||||
|
||||
case 'block:error': {
|
||||
updateActiveBlockRefCount(
|
||||
activeBlockRefCounts,
|
||||
activeBlocksSet,
|
||||
event.data.blockId,
|
||||
false
|
||||
)
|
||||
setActiveBlocks(wfId, new Set(activeBlocksSet))
|
||||
|
||||
setBlockRunStatus(wfId, event.data.blockId, 'error')
|
||||
markOutgoingEdgesFromOutput(
|
||||
event.data.blockId,
|
||||
{ error: event.data.error },
|
||||
workflowEdges,
|
||||
wfId,
|
||||
setEdgeRunStatus
|
||||
)
|
||||
|
||||
addConsole({
|
||||
input: event.data.input || {},
|
||||
output: {},
|
||||
success: false,
|
||||
error: event.data.error,
|
||||
durationMs: event.data.durationMs,
|
||||
startedAt: new Date(Date.now() - event.data.durationMs).toISOString(),
|
||||
executionOrder: event.data.executionOrder,
|
||||
endedAt: new Date().toISOString(),
|
||||
workflowId: targetWorkflowId,
|
||||
blockId: event.data.blockId,
|
||||
executionId,
|
||||
blockName: event.data.blockName,
|
||||
blockType: event.data.blockType,
|
||||
iterationCurrent: event.data.iterationCurrent,
|
||||
iterationTotal: event.data.iterationTotal,
|
||||
iterationType: event.data.iterationType,
|
||||
iterationContainerId: event.data.iterationContainerId,
|
||||
childWorkflowBlockId: event.data.childWorkflowBlockId,
|
||||
childWorkflowName: event.data.childWorkflowName,
|
||||
childWorkflowInstanceId: event.data.childWorkflowInstanceId,
|
||||
})
|
||||
case 'block:completed':
|
||||
blockHandlers.onBlockCompleted(event.data)
|
||||
break
|
||||
}
|
||||
|
||||
case 'block:childWorkflowStarted': {
|
||||
const { updateConsole } = useTerminalConsoleStore.getState()
|
||||
updateConsole(
|
||||
event.data.blockId,
|
||||
{
|
||||
childWorkflowInstanceId: event.data.childWorkflowInstanceId,
|
||||
...(event.data.iterationCurrent !== undefined && {
|
||||
iterationCurrent: event.data.iterationCurrent,
|
||||
}),
|
||||
...(event.data.iterationContainerId !== undefined && {
|
||||
iterationContainerId: event.data.iterationContainerId,
|
||||
}),
|
||||
},
|
||||
executionId
|
||||
)
|
||||
case 'block:error':
|
||||
blockHandlers.onBlockError(event.data)
|
||||
break
|
||||
|
||||
case 'block:childWorkflowStarted':
|
||||
blockHandlers.onBlockChildWorkflowStarted(event.data)
|
||||
break
|
||||
}
|
||||
|
||||
case 'execution:completed':
|
||||
setCurrentExecutionId(wfId, null)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Folder } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import type { FolderTreeNode } from '@/stores/folders/types'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
|
||||
interface CollapsedSidebarMenuProps {
|
||||
icon: React.ReactNode
|
||||
hover: ReturnType<typeof useHoverMenu>
|
||||
onClick?: () => void
|
||||
ariaLabel?: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CollapsedSidebarMenu({
|
||||
icon,
|
||||
hover,
|
||||
onClick,
|
||||
ariaLabel,
|
||||
children,
|
||||
className,
|
||||
}: CollapsedSidebarMenuProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col px-[8px]', className)}>
|
||||
<DropdownMenu
|
||||
open={hover.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open) hover.open()
|
||||
else hover.close()
|
||||
}}
|
||||
modal={false}
|
||||
>
|
||||
<div {...hover.triggerProps}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label={ariaLabel}
|
||||
className='mx-[2px] flex h-[30px] items-center rounded-[8px] px-[8px] hover:bg-[var(--surface-active)]'
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent side='right' align='start' sideOffset={8} {...hover.contentProps}>
|
||||
{children}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CollapsedFolderItems({
|
||||
nodes,
|
||||
workflowsByFolder,
|
||||
workspaceId,
|
||||
}: {
|
||||
nodes: FolderTreeNode[]
|
||||
workflowsByFolder: Record<string, WorkflowMetadata[]>
|
||||
workspaceId: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map((folder) => {
|
||||
const folderWorkflows = workflowsByFolder[folder.id] || []
|
||||
const hasChildren = folder.children.length > 0 || folderWorkflows.length > 0
|
||||
|
||||
if (!hasChildren) {
|
||||
return (
|
||||
<DropdownMenuItem key={folder.id} disabled>
|
||||
<Folder className='h-[14px] w-[14px]' />
|
||||
<span className='truncate'>{folder.name}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub key={folder.id}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Folder className='h-[14px] w-[14px]' />
|
||||
<span className='truncate'>{folder.name}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<CollapsedFolderItems
|
||||
nodes={folder.children}
|
||||
workflowsByFolder={workflowsByFolder}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
{folderWorkflows.map((workflow) => (
|
||||
<DropdownMenuItem key={workflow.id} asChild>
|
||||
<Link href={`/workspace/${workspaceId}/w/${workflow.id}`}>
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: workflow.color,
|
||||
borderColor: `${workflow.color}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
<span className='truncate'>{workflow.name}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
export {
|
||||
CollapsedFolderItems,
|
||||
CollapsedSidebarMenu,
|
||||
} from './collapsed-sidebar-menu/collapsed-sidebar-menu'
|
||||
export { HelpModal } from './help-modal/help-modal'
|
||||
export { NavItemContextMenu } from './nav-item-context-menu'
|
||||
export { SearchModal } from './search-modal/search-modal'
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { Badge, Skeleton } from '@/components/emcn'
|
||||
import { USAGE_PILL_COLORS, USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
|
||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||
import {
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
useSidebarDragContextValue,
|
||||
useWorkflowSelection,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import {
|
||||
compareByOrder,
|
||||
groupWorkflowsByFolder,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import type { FolderTreeNode } from '@/stores/folders/types'
|
||||
@@ -22,17 +26,6 @@ const TREE_SPACING = {
|
||||
INDENT_PER_LEVEL: 20,
|
||||
} as const
|
||||
|
||||
function compareByOrder<T extends { sortOrder: number; createdAt?: Date; id: string }>(
|
||||
a: T,
|
||||
b: T
|
||||
): number {
|
||||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
|
||||
const timeA = a.createdAt?.getTime() ?? 0
|
||||
const timeB = b.createdAt?.getTime() ?? 0
|
||||
if (timeA !== timeB) return timeA - timeB
|
||||
return a.id.localeCompare(b.id)
|
||||
}
|
||||
|
||||
interface WorkflowListProps {
|
||||
workspaceId: string
|
||||
workflowId: string | undefined
|
||||
@@ -129,21 +122,10 @@ export const WorkflowList = memo(function WorkflowList({
|
||||
return activeWorkflow?.folderId || null
|
||||
}, [workflowId, regularWorkflows, isLoading, foldersLoading])
|
||||
|
||||
const workflowsByFolder = useMemo(() => {
|
||||
const grouped = regularWorkflows.reduce(
|
||||
(acc, workflow) => {
|
||||
const folderId = workflow.folderId || 'root'
|
||||
if (!acc[folderId]) acc[folderId] = []
|
||||
acc[folderId].push(workflow)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, WorkflowMetadata[]>
|
||||
)
|
||||
for (const folderId of Object.keys(grouped)) {
|
||||
grouped[folderId].sort(compareByOrder)
|
||||
}
|
||||
return grouped
|
||||
}, [regularWorkflows])
|
||||
const workflowsByFolder = useMemo(
|
||||
() => groupWorkflowsByFolder(regularWorkflows),
|
||||
[regularWorkflows]
|
||||
)
|
||||
|
||||
const orderedWorkflowIds = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2, RotateCw, X } from 'lucide-react'
|
||||
import { Badge, Button, Tooltip } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge, Button, Skeleton, Tooltip } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
|
||||
@@ -4,6 +4,7 @@ export { type DropIndicator, useDragDrop } from './use-drag-drop'
|
||||
export { useFolderExpand } from './use-folder-expand'
|
||||
export { useFolderOperations } from './use-folder-operations'
|
||||
export { useFolderSelection } from './use-folder-selection'
|
||||
export { useHoverMenu } from './use-hover-menu'
|
||||
export { useItemDrag } from './use-item-drag'
|
||||
export { useItemRename } from './use-item-rename'
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
const CLOSE_DELAY_MS = 150
|
||||
|
||||
const preventAutoFocus = (e: Event) => e.preventDefault()
|
||||
|
||||
/**
|
||||
* Manages hover-triggered dropdown menu state.
|
||||
* Provides handlers for trigger and content mouse events with a delay
|
||||
* to prevent flickering when moving between trigger and content.
|
||||
*/
|
||||
export function useHoverMenu() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const cancelClose = useCallback(() => {
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
cancelClose()
|
||||
closeTimerRef.current = setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS)
|
||||
}, [cancelClose])
|
||||
|
||||
const open = useCallback(() => {
|
||||
cancelClose()
|
||||
setIsOpen(true)
|
||||
}, [cancelClose])
|
||||
|
||||
const close = useCallback(() => {
|
||||
cancelClose()
|
||||
setIsOpen(false)
|
||||
}, [cancelClose])
|
||||
|
||||
const triggerProps = useMemo(
|
||||
() => ({ onMouseEnter: open, onMouseLeave: scheduleClose }) as const,
|
||||
[open, scheduleClose]
|
||||
)
|
||||
|
||||
const contentProps = useMemo(
|
||||
() =>
|
||||
({
|
||||
onMouseEnter: cancelClose,
|
||||
onMouseLeave: scheduleClose,
|
||||
onCloseAutoFocus: preventAutoFocus,
|
||||
}) as const,
|
||||
[cancelClose, scheduleClose]
|
||||
)
|
||||
|
||||
return { isOpen, open, close, triggerProps, contentProps }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -38,6 +38,8 @@ import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/provide
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import {
|
||||
CollapsedFolderItems,
|
||||
CollapsedSidebarMenu,
|
||||
HelpModal,
|
||||
NavItemContextMenu,
|
||||
SearchModal,
|
||||
@@ -50,17 +52,20 @@ import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/
|
||||
import {
|
||||
useContextMenu,
|
||||
useFolderOperations,
|
||||
useHoverMenu,
|
||||
useSidebarResize,
|
||||
useTaskSelection,
|
||||
useWorkflowOperations,
|
||||
useWorkspaceManagement,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { groupWorkflowsByFolder } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
|
||||
import {
|
||||
useDuplicateWorkspace,
|
||||
useExportWorkspace,
|
||||
useImportWorkflow,
|
||||
useImportWorkspace,
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import { useDeleteTask, useDeleteTasks, useRenameTask, useTasks } from '@/hooks/queries/tasks'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
@@ -74,7 +79,7 @@ const logger = createLogger('Sidebar')
|
||||
|
||||
function SidebarItemSkeleton() {
|
||||
return (
|
||||
<div className='mx-[2px] flex h-[30px] items-center px-[8px]'>
|
||||
<div className='sidebar-collapse-hide mx-[2px] flex h-[30px] items-center px-[8px]'>
|
||||
<Skeleton className='h-[24px] w-full rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
@@ -265,6 +270,12 @@ export const Sidebar = memo(function Sidebar() {
|
||||
|
||||
const [showCollapsedContent, setShowCollapsedContent] = useState(isCollapsed)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isCollapsed) {
|
||||
document.documentElement.removeAttribute('data-sidebar-collapsed')
|
||||
}
|
||||
}, [isCollapsed])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCollapsed) {
|
||||
const timer = setTimeout(() => setShowCollapsedContent(true), 200)
|
||||
@@ -356,6 +367,20 @@ export const Sidebar = memo(function Sidebar() {
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
useFolders(workspaceId)
|
||||
const folders = useFolderStore((s) => s.folders)
|
||||
const getFolderTree = useFolderStore((s) => s.getFolderTree)
|
||||
|
||||
const folderTree = useMemo(
|
||||
() => (isCollapsed && workspaceId ? getFolderTree(workspaceId) : []),
|
||||
[isCollapsed, workspaceId, folders, getFolderTree]
|
||||
)
|
||||
|
||||
const workflowsByFolder = useMemo(
|
||||
() => (isCollapsed ? groupWorkflowsByFolder(regularWorkflows) : {}),
|
||||
[isCollapsed, regularWorkflows]
|
||||
)
|
||||
|
||||
const [activeNavItemHref, setActiveNavItemHref] = useState<string | null>(null)
|
||||
const {
|
||||
isOpen: isNavContextMenuOpen,
|
||||
@@ -632,6 +657,8 @@ export const Sidebar = memo(function Sidebar() {
|
||||
const [visibleTaskCount, setVisibleTaskCount] = useState(5)
|
||||
const [renamingTaskId, setRenamingTaskId] = useState<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const tasksHover = useHoverMenu()
|
||||
const workflowsHover = useHoverMenu()
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
const renameCanceledRef = useRef(false)
|
||||
|
||||
@@ -960,7 +987,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
type='button'
|
||||
onClick={toggleCollapsed}
|
||||
className={cn(
|
||||
'ml-auto flex h-[30px] items-center justify-center overflow-hidden rounded-[8px] transition-all duration-200 hover:bg-[var(--surface-active)]',
|
||||
'sidebar-collapse-btn ml-auto flex h-[30px] items-center justify-center overflow-hidden rounded-[8px] transition-all duration-200 hover:bg-[var(--surface-active)]',
|
||||
isCollapsed ? 'w-0 opacity-0' : 'w-[30px] opacity-100'
|
||||
)}
|
||||
aria-label='Collapse sidebar'
|
||||
@@ -1023,13 +1050,11 @@ export const Sidebar = memo(function Sidebar() {
|
||||
|
||||
{/* Workspace */}
|
||||
<div className='mt-[14px] flex flex-shrink-0 flex-col pb-[8px]'>
|
||||
<div className='px-[16px] pb-[6px]'>
|
||||
<div
|
||||
className={`font-base text-[var(--text-icon)] text-small${isCollapsed ? ' opacity-0' : ''}`}
|
||||
>
|
||||
Workspace
|
||||
{!isCollapsed && (
|
||||
<div className='sidebar-collapse-remove px-[16px] pb-[6px]'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>Workspace</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex flex-col gap-[2px] px-[8px]'>
|
||||
{workspaceNavItems.map((item) => (
|
||||
<SidebarNavItem
|
||||
@@ -1053,99 +1078,170 @@ export const Sidebar = memo(function Sidebar() {
|
||||
>
|
||||
{/* Tasks */}
|
||||
<div className='flex flex-shrink-0 flex-col'>
|
||||
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div
|
||||
className={cn(
|
||||
'font-base text-[var(--text-icon)] text-small',
|
||||
isCollapsed && 'opacity-0'
|
||||
)}
|
||||
>
|
||||
All tasks
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className='flex items-center justify-center gap-[8px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
|
||||
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>New task</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<CollapsedSidebarMenu
|
||||
icon={
|
||||
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
}
|
||||
hover={tasksHover}
|
||||
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
|
||||
ariaLabel='Tasks'
|
||||
>
|
||||
{tasksLoading ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<Loader className='h-[14px] w-[14px]' animate />
|
||||
Loading...
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<DropdownMenuItem key={task.id} asChild>
|
||||
<Link href={task.href}>
|
||||
<Blimp className='h-[16px] w-[16px]' />
|
||||
<span>{task.name}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-[6px] flex flex-col gap-[2px] px-[8px]'>
|
||||
{tasksLoading ? (
|
||||
<SidebarItemSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{tasks.slice(0, visibleTaskCount).map((task) => {
|
||||
const isCurrentRoute = task.id !== 'new' && pathname === task.href
|
||||
const isRenaming = renamingTaskId === task.id
|
||||
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
|
||||
</CollapsedSidebarMenu>
|
||||
) : (
|
||||
<div className='sidebar-collapse-remove'>
|
||||
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>
|
||||
All tasks
|
||||
</div>
|
||||
<div className='flex items-center justify-center gap-[8px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
|
||||
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>New task</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-[6px] flex flex-col gap-[2px] px-[8px]'>
|
||||
{tasksLoading ? (
|
||||
<SidebarItemSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{tasks.slice(0, visibleTaskCount).map((task) => {
|
||||
const isCurrentRoute = task.id !== 'new' && pathname === task.href
|
||||
const isRenaming = renamingTaskId === task.id
|
||||
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
|
||||
|
||||
if (!isCollapsed && isRenaming) {
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] bg-[var(--surface-active)] px-[8px] text-[14px]'
|
||||
>
|
||||
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={handleRenameKeyDown}
|
||||
onBlur={handleSaveTaskRename}
|
||||
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] bg-[var(--surface-active)] px-[8px] text-[14px]'
|
||||
>
|
||||
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={handleRenameKeyDown}
|
||||
onBlur={handleSaveTaskRename}
|
||||
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarTaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isCurrentRoute={isCurrentRoute}
|
||||
isSelected={isSelected}
|
||||
isActive={!!task.isActive}
|
||||
isUnread={!!task.isUnread}
|
||||
showCollapsedContent={showCollapsedContent}
|
||||
onMultiSelectClick={handleTaskClick}
|
||||
onContextMenu={handleTaskContextMenu}
|
||||
onMorePointerDown={handleTaskMorePointerDown}
|
||||
onMoreClick={handleTaskMoreClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarTaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isCurrentRoute={isCurrentRoute}
|
||||
isSelected={isSelected}
|
||||
isActive={!!task.isActive}
|
||||
isUnread={!!task.isUnread}
|
||||
showCollapsedContent={showCollapsedContent}
|
||||
onMultiSelectClick={handleTaskClick}
|
||||
onContextMenu={handleTaskContextMenu}
|
||||
onMorePointerDown={handleTaskMorePointerDown}
|
||||
onMoreClick={handleTaskMoreClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{tasks.length > visibleTaskCount && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setVisibleTaskCount((prev) => prev + 5)}
|
||||
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] text-[var(--text-icon)] hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
|
||||
<span className='font-base'>See more</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{tasks.length > visibleTaskCount && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setVisibleTaskCount((prev) => prev + 5)}
|
||||
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] text-[var(--text-icon)] hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
|
||||
<span className='font-base'>See more</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflows */}
|
||||
{!isCollapsed && (
|
||||
<div className='workflows-section relative mt-[14px] flex flex-col'>
|
||||
{isCollapsed ? (
|
||||
<CollapsedSidebarMenu
|
||||
icon={
|
||||
<div
|
||||
className='h-[16px] w-[16px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: 'var(--text-icon)',
|
||||
borderColor: 'color-mix(in srgb, var(--text-icon) 60%, transparent)',
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
hover={workflowsHover}
|
||||
onClick={handleCreateWorkflow}
|
||||
ariaLabel='Workflows'
|
||||
className='mt-[14px]'
|
||||
>
|
||||
{workflowsLoading && regularWorkflows.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<Loader className='h-[14px] w-[14px]' animate />
|
||||
Loading...
|
||||
</DropdownMenuItem>
|
||||
) : regularWorkflows.length === 0 ? (
|
||||
<DropdownMenuItem disabled>No workflows yet</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<CollapsedFolderItems
|
||||
nodes={folderTree}
|
||||
workflowsByFolder={workflowsByFolder}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
{(workflowsByFolder.root || []).map((workflow) => (
|
||||
<DropdownMenuItem key={workflow.id} asChild>
|
||||
<Link href={`/workspace/${workspaceId}/w/${workflow.id}`}>
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: workflow.color,
|
||||
borderColor: `${workflow.color}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
<span className='truncate'>{workflow.name}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CollapsedSidebarMenu>
|
||||
) : (
|
||||
<div className='sidebar-collapse-remove workflows-section relative mt-[14px] flex flex-col'>
|
||||
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
|
||||
export function compareByOrder<T extends { sortOrder: number; createdAt?: Date; id: string }>(
|
||||
a: T,
|
||||
b: T
|
||||
): number {
|
||||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
|
||||
const timeA = a.createdAt?.getTime() ?? 0
|
||||
const timeB = b.createdAt?.getTime() ?? 0
|
||||
if (timeA !== timeB) return timeA - timeB
|
||||
return a.id.localeCompare(b.id)
|
||||
}
|
||||
|
||||
export function groupWorkflowsByFolder(
|
||||
workflows: WorkflowMetadata[]
|
||||
): Record<string, WorkflowMetadata[]> {
|
||||
const grouped = workflows.reduce(
|
||||
(acc, workflow) => {
|
||||
const folderId = workflow.folderId || 'root'
|
||||
if (!acc[folderId]) acc[folderId] = []
|
||||
acc[folderId].push(workflow)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, WorkflowMetadata[]>
|
||||
)
|
||||
for (const key of Object.keys(grouped)) {
|
||||
grouped[key].sort(compareByOrder)
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
@@ -4,14 +4,12 @@ import { useEffect } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useReferralAttribution } from '@/hooks/use-referral-attribution'
|
||||
|
||||
const logger = createLogger('WorkspacePage')
|
||||
|
||||
export default function WorkspacePage() {
|
||||
const router = useRouter()
|
||||
const { data: session, isPending } = useSession()
|
||||
useReferralAttribution()
|
||||
|
||||
useEffect(() => {
|
||||
const redirectToFirstWorkspace = async () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('font-semibold text-2xl leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -1,11 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -1,184 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className='ml-auto' />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className='h-4 w-4' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className='h-2 w-2 fill-current' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 font-semibold text-sm', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||
}
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
export { Alert, AlertDescription, AlertTitle } from './alert'
|
||||
export { Button, buttonVariants } from './button'
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './card'
|
||||
export { Collapsible, CollapsibleContent, CollapsibleTrigger } from './collapsible'
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -14,28 +12,10 @@ export {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './dialog'
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from './dropdown-menu'
|
||||
export { Input } from './input'
|
||||
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from './input-otp'
|
||||
export { Label } from './label'
|
||||
export { Progress } from './progress'
|
||||
export { ScrollArea, ScrollBar } from './scroll-area'
|
||||
export { SearchHighlight } from './search-highlight'
|
||||
export {
|
||||
Select,
|
||||
@@ -49,7 +29,3 @@ export {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './select'
|
||||
export { Separator } from './separator'
|
||||
export { Skeleton } from './skeleton'
|
||||
export { TagInput } from './tag-input'
|
||||
export { ToolCallCompletion, ToolCallExecution } from './tool-call'
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
|
||||
hideScrollbar?: boolean
|
||||
}
|
||||
>(({ className, children, hideScrollbar = false, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className='h-full w-full rounded-[inherit]'>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar hidden={hideScrollbar} />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> & {
|
||||
hidden?: boolean
|
||||
}
|
||||
>(({ className, orientation = 'vertical', hidden = false, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
hidden && 'pointer-events-none w-0 border-0 p-0 opacity-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
className={cn('relative flex-1 rounded-full bg-border', hidden && 'hidden')}
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -1,25 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -1,7 +0,0 @@
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -1,112 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { type KeyboardEvent, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface TagInputProps {
|
||||
value: string[]
|
||||
onChange: (tags: string[]) => void
|
||||
placeholder?: string
|
||||
maxTags?: number
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TagInput({
|
||||
value = [],
|
||||
onChange,
|
||||
placeholder = 'Type and press Enter',
|
||||
maxTags = 10,
|
||||
disabled = false,
|
||||
className,
|
||||
}: TagInputProps) {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
const trimmedTag = tag.trim()
|
||||
if (trimmedTag && !value.includes(trimmedTag) && value.length < maxTags) {
|
||||
onChange([...value, trimmedTag])
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
onChange(value.filter((tag) => tag !== tagToRemove))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && inputValue.trim()) {
|
||||
e.preventDefault()
|
||||
addTag(inputValue)
|
||||
} else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
|
||||
removeTag(value[value.length - 1])
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
if (inputValue.trim()) {
|
||||
addTag(inputValue)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[4px] focus-within:outline-none',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
className
|
||||
)}
|
||||
onClick={() => !disabled && inputRef.current?.focus()}
|
||||
>
|
||||
{value.map((tag) => (
|
||||
<Tag key={tag} value={tag} onRemove={() => removeTag(tag)} disabled={disabled} />
|
||||
))}
|
||||
{!disabled && value.length < maxTags && (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
placeholder={value.length === 0 ? placeholder : ''}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'h-6 min-w-[180px] flex-1 border-none bg-transparent p-0 font-medium font-sans text-sm placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
value.length > 0 ? 'pl-[4px]' : 'pl-[4px]'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TagProps {
|
||||
value: string
|
||||
onRemove: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function Tag({ value, onRemove, disabled }: TagProps) {
|
||||
return (
|
||||
<div className='flex w-auto items-center gap-[4px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[6px] py-[2px] text-[12px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'>
|
||||
<span className='max-w-[200px] truncate'>{value}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove()
|
||||
}}
|
||||
className='flex-shrink-0 text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)] focus:outline-none'
|
||||
aria-label={`Remove ${value}`}
|
||||
>
|
||||
<X className='h-[12px] w-[12px] translate-y-[0.2px]' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle, ChevronDown, ChevronRight, Loader2, Settings, XCircle } from 'lucide-react'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
|
||||
interface ToolCallState {
|
||||
id: string
|
||||
name: string
|
||||
displayName?: string
|
||||
parameters?: Record<string, unknown>
|
||||
state:
|
||||
| 'detecting'
|
||||
| 'pending'
|
||||
| 'executing'
|
||||
| 'completed'
|
||||
| 'error'
|
||||
| 'rejected'
|
||||
| 'applied'
|
||||
| 'ready_for_review'
|
||||
| 'aborted'
|
||||
| 'skipped'
|
||||
| 'background'
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
duration?: number
|
||||
result?: unknown
|
||||
error?: string
|
||||
progress?: string
|
||||
}
|
||||
|
||||
interface ToolCallGroup {
|
||||
id: string
|
||||
toolCalls: ToolCallState[]
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'error'
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
summary?: string
|
||||
}
|
||||
|
||||
interface ToolCallProps {
|
||||
toolCall: ToolCallState
|
||||
isCompact?: boolean
|
||||
}
|
||||
|
||||
interface ToolCallGroupProps {
|
||||
group: ToolCallGroup
|
||||
isCompact?: boolean
|
||||
}
|
||||
|
||||
interface ToolCallIndicatorProps {
|
||||
type: 'status' | 'thinking' | 'execution'
|
||||
content: string
|
||||
toolNames?: string[]
|
||||
}
|
||||
|
||||
// Detection State Component
|
||||
export function ToolCallDetection({ content }: { content: string }) {
|
||||
return (
|
||||
<div className='flex min-w-0 items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm dark:border-blue-800 dark:bg-blue-950'>
|
||||
<Loader2 className='h-4 w-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400' />
|
||||
<span className='min-w-0 truncate text-blue-800 dark:text-blue-200'>{content}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Execution State Component
|
||||
export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!isCompact)
|
||||
|
||||
return (
|
||||
<div className='min-w-0 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950'>
|
||||
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='w-full min-w-0 justify-between px-3 py-4 hover:bg-amber-100 dark:hover:bg-amber-900'
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
<Settings className='h-4 w-4 shrink-0 animate-pulse text-amber-600 dark:text-amber-400' />
|
||||
<span className='min-w-0 truncate font-mono text-amber-800 text-xs dark:text-amber-200'>
|
||||
{toolCall.displayName || toolCall.name}
|
||||
</span>
|
||||
{toolCall.progress && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='shrink-0 text-amber-700 text-xs dark:text-amber-300'
|
||||
>
|
||||
{toolCall.progress}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className='h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400' />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='min-w-0 max-w-full px-3 pb-3'>
|
||||
<div className='min-w-0 max-w-full space-y-2'>
|
||||
<div className='flex items-center gap-2 text-amber-700 text-xs dark:text-amber-300'>
|
||||
<Loader2 className='h-3 w-3 shrink-0 animate-spin' />
|
||||
<span>Executing...</span>
|
||||
</div>
|
||||
{toolCall.parameters &&
|
||||
Object.keys(toolCall.parameters).length > 0 &&
|
||||
(toolCall.name === 'make_api_request' ||
|
||||
toolCall.name === 'set_environment_variables' ||
|
||||
toolCall.name === 'set_global_workflow_variables') && (
|
||||
<div className='min-w-0 max-w-full rounded border border-amber-200 bg-amber-50 p-2 dark:border-amber-800 dark:bg-amber-950'>
|
||||
{toolCall.name === 'make_api_request' ? (
|
||||
<div className='w-full overflow-hidden rounded border border-muted bg-card'>
|
||||
<div className='grid grid-cols-2 gap-0 border-muted/60 border-b bg-muted/40 px-2 py-1.5'>
|
||||
<div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'>
|
||||
Method
|
||||
</div>
|
||||
<div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'>
|
||||
Endpoint
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid grid-cols-[auto_1fr] items-center gap-2 px-2 py-2'>
|
||||
<div>
|
||||
<span className='inline-flex rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground text-xs'>
|
||||
{String((toolCall.parameters as any).method || '').toUpperCase() ||
|
||||
'GET'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<span
|
||||
className='block overflow-x-auto whitespace-nowrap font-mono text-foreground text-xs'
|
||||
title={String((toolCall.parameters as any).url || '')}
|
||||
>
|
||||
{String((toolCall.parameters as any).url || '') || 'URL not provided'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{toolCall.name === 'set_environment_variables'
|
||||
? (() => {
|
||||
const variables =
|
||||
(toolCall.parameters as any).variables &&
|
||||
typeof (toolCall.parameters as any).variables === 'object'
|
||||
? (toolCall.parameters as any).variables
|
||||
: {}
|
||||
const entries = Object.entries(variables)
|
||||
return (
|
||||
<div className='w-full overflow-hidden rounded border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950'>
|
||||
<div className='grid grid-cols-2 gap-0 border-amber-200/60 border-b px-2 py-1.5 dark:border-amber-800/60'>
|
||||
<div className='font-medium text-[10px] text-amber-700 uppercase tracking-wide dark:text-amber-300'>
|
||||
Name
|
||||
</div>
|
||||
<div className='font-medium text-[10px] text-amber-700 uppercase tracking-wide dark:text-amber-300'>
|
||||
Value
|
||||
</div>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<div className='px-2 py-2 text-muted-foreground text-xs'>
|
||||
No variables provided
|
||||
</div>
|
||||
) : (
|
||||
<div className='divide-y divide-amber-200 dark:divide-amber-800'>
|
||||
{entries.map(([k, v]) => (
|
||||
<div
|
||||
key={k}
|
||||
className='grid grid-cols-[auto_1fr] items-center gap-2 px-2 py-1.5'
|
||||
>
|
||||
<div className='truncate font-medium text-amber-800 text-xs dark:text-amber-200'>
|
||||
{k}
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<span className='block overflow-x-auto whitespace-nowrap font-mono text-amber-700 text-xs dark:text-amber-300'>
|
||||
{String(v)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
: null}
|
||||
|
||||
{toolCall.name === 'set_global_workflow_variables'
|
||||
? (() => {
|
||||
const ops = Array.isArray((toolCall.parameters as any).operations)
|
||||
? ((toolCall.parameters as any).operations as any[])
|
||||
: []
|
||||
return (
|
||||
<div className='w-full overflow-hidden rounded border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950'>
|
||||
<div className='grid grid-cols-3 gap-0 border-amber-200/60 border-b px-2 py-1.5 dark:border-amber-800/60'>
|
||||
<div className='font-medium text-[10px] text-amber-700 uppercase tracking-wide dark:text-amber-300'>
|
||||
Name
|
||||
</div>
|
||||
<div className='font-medium text-[10px] text-amber-700 uppercase tracking-wide dark:text-amber-300'>
|
||||
Type
|
||||
</div>
|
||||
<div className='font-medium text-[10px] text-amber-700 uppercase tracking-wide dark:text-amber-300'>
|
||||
Value
|
||||
</div>
|
||||
</div>
|
||||
{ops.length === 0 ? (
|
||||
<div className='px-2 py-2 text-muted-foreground text-xs'>
|
||||
No operations provided
|
||||
</div>
|
||||
) : (
|
||||
<div className='divide-y divide-amber-200 dark:divide-amber-800'>
|
||||
{ops.map((op, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className='grid grid-cols-3 items-center gap-0 px-2 py-1.5'
|
||||
>
|
||||
<div className='min-w-0'>
|
||||
<span className='truncate text-amber-800 text-xs dark:text-amber-200'>
|
||||
{String(op.name || '')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className='rounded border px-1 py-0.5 text-[10px] text-muted-foreground'>
|
||||
{String(op.type || '')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
{op.value !== undefined ? (
|
||||
<span className='block overflow-x-auto whitespace-nowrap font-mono text-amber-700 text-xs dark:text-amber-300'>
|
||||
{String(op.value)}
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-xs'>—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
: null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const isSuccess = toolCall.state === 'completed'
|
||||
const isError = toolCall.state === 'error'
|
||||
const isAborted = toolCall.state === 'aborted'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 rounded-lg border',
|
||||
isSuccess && 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950',
|
||||
isError && 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950',
|
||||
isAborted && 'border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950'
|
||||
)}
|
||||
>
|
||||
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className={cn(
|
||||
'w-full min-w-0 justify-between px-3 py-4',
|
||||
isSuccess && 'hover:bg-green-100 dark:hover:bg-green-900',
|
||||
isError && 'hover:bg-red-100 dark:hover:bg-red-900',
|
||||
isAborted && 'hover:bg-orange-100 dark:hover:bg-orange-900'
|
||||
)}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{isSuccess && (
|
||||
<CheckCircle className='h-4 w-4 shrink-0 text-green-600 dark:text-green-400' />
|
||||
)}
|
||||
{isError && <XCircle className='h-4 w-4 shrink-0 text-red-600 dark:text-red-400' />}
|
||||
{isAborted && (
|
||||
<XCircle className='h-4 w-4 shrink-0 text-orange-600 dark:text-orange-400' />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 truncate font-mono text-xs',
|
||||
isSuccess && 'text-green-800 dark:text-green-200',
|
||||
isError && 'text-red-800 dark:text-red-200',
|
||||
isAborted && 'text-orange-800 dark:text-orange-200'
|
||||
)}
|
||||
>
|
||||
{toolCall.displayName || toolCall.name}
|
||||
</span>
|
||||
{toolCall.duration && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'shrink-0 text-xs',
|
||||
isSuccess && 'text-green-700 dark:text-green-300',
|
||||
isError && 'text-red-700 dark:text-red-300',
|
||||
isAborted && 'text-orange-700 dark:text-orange-300'
|
||||
)}
|
||||
style={{ fontSize: '0.625rem' }}
|
||||
>
|
||||
{toolCall.duration ? formatDuration(toolCall.duration, { precision: 1 }) : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center'>
|
||||
{isExpanded ? (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
isSuccess && 'text-green-600 dark:text-green-400',
|
||||
isError && 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
isSuccess && 'text-green-600 dark:text-green-400',
|
||||
isError && 'text-red-600 dark:text-red-400'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='min-w-0 max-w-full px-3 pb-3'>
|
||||
<div className='min-w-0 max-w-full space-y-2'>
|
||||
{toolCall.parameters &&
|
||||
Object.keys(toolCall.parameters).length > 0 &&
|
||||
(toolCall.name === 'make_api_request' ||
|
||||
toolCall.name === 'set_environment_variables') && (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full rounded p-2',
|
||||
isSuccess && 'bg-green-100 dark:bg-green-900',
|
||||
isError && 'bg-red-100 dark:bg-red-900'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mb-1 font-medium text-xs',
|
||||
isSuccess && 'text-green-800 dark:text-green-200',
|
||||
isError && 'text-red-800 dark:text-red-200'
|
||||
)}
|
||||
>
|
||||
Parameters:
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full break-all font-mono text-xs',
|
||||
isSuccess && 'text-green-700 dark:text-green-300',
|
||||
isError && 'text-red-700 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{JSON.stringify(toolCall.parameters, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolCall.error && (
|
||||
<div className='min-w-0 max-w-full rounded bg-red-100 p-2 dark:bg-red-900'>
|
||||
<div className='mb-1 font-medium text-red-800 text-xs dark:text-red-200'>
|
||||
Error:
|
||||
</div>
|
||||
<div className='min-w-0 max-w-full break-all font-mono text-red-700 text-xs dark:text-red-300'>
|
||||
{toolCall.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Group Component for Multiple Tool Calls
|
||||
export function ToolCallGroupComponent({ group, isCompact = false }: ToolCallGroupProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
|
||||
const completedCount = group.toolCalls.filter((t) => t.state === 'completed').length
|
||||
const totalCount = group.toolCalls.length
|
||||
const isAllCompleted = completedCount === totalCount
|
||||
const hasErrors = group.toolCalls.some((t) => t.state === 'error')
|
||||
|
||||
return (
|
||||
<div className='min-w-0 space-y-2'>
|
||||
{group.summary && (
|
||||
<div className='flex min-w-0 items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm dark:border-blue-800 dark:bg-blue-950'>
|
||||
<Settings className='h-4 w-4 shrink-0 text-blue-600 dark:text-blue-400' />
|
||||
<span className='min-w-0 truncate text-blue-800 dark:text-blue-200'>{group.summary}</span>
|
||||
{!isAllCompleted && (
|
||||
<Badge variant='outline' className='shrink-0 text-blue-700 text-xs dark:text-blue-300'>
|
||||
{completedCount}/{totalCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='w-full min-w-0 justify-between px-3 py-3 text-sm hover:bg-muted'
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
<span className='min-w-0 truncate text-muted-foreground'>
|
||||
{isAllCompleted ? 'Completed' : 'In Progress'} ({completedCount}/{totalCount})
|
||||
</span>
|
||||
{hasErrors && (
|
||||
<Badge variant='red' className='shrink-0 text-xs'>
|
||||
Errors
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className='h-4 w-4 shrink-0 text-muted-foreground' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4 shrink-0 text-muted-foreground' />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='min-w-0 max-w-full space-y-2'>
|
||||
{group.toolCalls.map((toolCall) => (
|
||||
<div key={toolCall.id} className='min-w-0 max-w-full'>
|
||||
{toolCall.state === 'executing' && (
|
||||
<ToolCallExecution toolCall={toolCall} isCompact={isCompact} />
|
||||
)}
|
||||
{(toolCall.state === 'completed' || toolCall.state === 'error') && (
|
||||
<ToolCallCompletion toolCall={toolCall} isCompact={isCompact} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Status Indicator Component
|
||||
export function ToolCallIndicator({ type, content, toolNames }: ToolCallIndicatorProps) {
|
||||
if (type === 'status' && toolNames) {
|
||||
return (
|
||||
<div className='flex min-w-0 items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm dark:border-blue-800 dark:bg-blue-950'>
|
||||
<Loader2 className='h-4 w-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400' />
|
||||
<span className='min-w-0 truncate text-blue-800 dark:text-blue-200'>
|
||||
🔄 {toolNames.join(' • ')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm dark:border-blue-800 dark:bg-blue-950'>
|
||||
<Loader2 className='h-4 w-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400' />
|
||||
<span className='min-w-0 truncate text-blue-800 dark:text-blue-200'>{content}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -237,14 +237,27 @@ export const googleCalendarConnector: ConnectorConfig = {
|
||||
auth: {
|
||||
mode: 'oauth',
|
||||
provider: 'google-calendar',
|
||||
requiredScopes: ['https://www.googleapis.com/auth/calendar.readonly'],
|
||||
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
|
||||
},
|
||||
|
||||
configFields: [
|
||||
{
|
||||
id: 'calendarSelector',
|
||||
title: 'Calendar',
|
||||
type: 'selector',
|
||||
selectorKey: 'google.calendar',
|
||||
canonicalParamId: 'calendarId',
|
||||
mode: 'basic',
|
||||
placeholder: 'Select a calendar',
|
||||
required: false,
|
||||
description: 'The calendar to sync from. Defaults to your primary calendar.',
|
||||
},
|
||||
{
|
||||
id: 'calendarId',
|
||||
title: 'Calendar ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'calendarId',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. primary (default: primary)',
|
||||
required: false,
|
||||
description: 'The calendar to sync from. Use "primary" for your main calendar.',
|
||||
|
||||
@@ -171,7 +171,7 @@ export const googleSheetsConnector: ConnectorConfig = {
|
||||
auth: {
|
||||
mode: 'oauth',
|
||||
provider: 'google-sheets',
|
||||
requiredScopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
|
||||
requiredScopes: ['https://www.googleapis.com/auth/drive'],
|
||||
},
|
||||
|
||||
configFields: [
|
||||
|
||||
@@ -91,10 +91,23 @@ export const jiraConnector: ConnectorConfig = {
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -193,17 +193,42 @@ export const linearConnector: ConnectorConfig = {
|
||||
auth: { mode: 'oauth', provider: 'linear', requiredScopes: ['read'] },
|
||||
|
||||
configFields: [
|
||||
{
|
||||
id: 'teamSelector',
|
||||
title: 'Team',
|
||||
type: 'selector',
|
||||
selectorKey: 'linear.teams',
|
||||
canonicalParamId: 'teamId',
|
||||
mode: 'basic',
|
||||
placeholder: 'Select a team (optional)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'teamId',
|
||||
title: 'Team ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'teamId',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. abc123 (leave empty for all teams)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'projectSelector',
|
||||
title: 'Project',
|
||||
type: 'selector',
|
||||
selectorKey: 'linear.projects',
|
||||
canonicalParamId: 'projectId',
|
||||
mode: 'basic',
|
||||
dependsOn: ['teamSelector'],
|
||||
placeholder: 'Select a project (optional)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'projectId',
|
||||
title: 'Project ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'projectId',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. def456 (leave empty for all projects)',
|
||||
required: false,
|
||||
},
|
||||
|
||||
@@ -195,18 +195,43 @@ export const microsoftTeamsConnector: ConnectorConfig = {
|
||||
},
|
||||
|
||||
configFields: [
|
||||
{
|
||||
id: 'teamSelector',
|
||||
title: 'Team',
|
||||
type: 'selector',
|
||||
selectorKey: 'microsoft.teams',
|
||||
canonicalParamId: 'teamId',
|
||||
mode: 'basic',
|
||||
placeholder: 'Select a team',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'teamId',
|
||||
title: 'Team ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'teamId',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. fbe2bf47-16c8-47cf-b4a5-4b9b187c508b',
|
||||
required: true,
|
||||
description: 'The ID of the Microsoft Teams team',
|
||||
},
|
||||
{
|
||||
id: 'channelSelector',
|
||||
title: 'Channel',
|
||||
type: 'selector',
|
||||
selectorKey: 'microsoft.channels',
|
||||
canonicalParamId: 'channel',
|
||||
mode: 'basic',
|
||||
dependsOn: ['teamSelector'],
|
||||
placeholder: 'Select a channel',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'channel',
|
||||
title: 'Channel',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'channel',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. General or 19:abc123@thread.tacv2',
|
||||
required: true,
|
||||
description: 'Channel name or ID to sync messages from',
|
||||
|
||||
@@ -191,10 +191,22 @@ export const notionConnector: ConnectorConfig = {
|
||||
{ label: 'Specific page (and children)', id: 'page' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'databaseSelector',
|
||||
title: 'Database',
|
||||
type: 'selector',
|
||||
selectorKey: 'notion.databases',
|
||||
canonicalParamId: 'databaseId',
|
||||
mode: 'basic',
|
||||
placeholder: 'Select a database',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'databaseId',
|
||||
title: 'Database ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'databaseId',
|
||||
mode: 'advanced',
|
||||
required: false,
|
||||
placeholder: 'e.g. 8a3b5f6e-1234-5678-abcd-ef0123456789',
|
||||
},
|
||||
|
||||
@@ -256,10 +256,22 @@ export const outlookConnector: ConnectorConfig = {
|
||||
},
|
||||
|
||||
configFields: [
|
||||
{
|
||||
id: 'folderSelector',
|
||||
title: 'Folder',
|
||||
type: 'selector',
|
||||
selectorKey: 'outlook.folders',
|
||||
canonicalParamId: 'folder',
|
||||
mode: 'basic',
|
||||
placeholder: 'Select a folder',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Folder',
|
||||
type: 'dropdown',
|
||||
canonicalParamId: 'folder',
|
||||
mode: 'advanced',
|
||||
required: false,
|
||||
options: [
|
||||
{ label: 'Inbox', id: 'inbox' },
|
||||
|
||||
@@ -241,6 +241,7 @@ export const redditConnector: ConnectorConfig = {
|
||||
auth: {
|
||||
mode: 'oauth',
|
||||
provider: 'reddit',
|
||||
requiredScopes: ['read'],
|
||||
},
|
||||
|
||||
configFields: [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user