Compare commits

..

29 Commits

Author SHA1 Message Date
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
448 changed files with 7539 additions and 29370 deletions

View File

@@ -1,35 +0,0 @@
---
paths:
- "apps/sim/components/emcn/**"
---
# EMCN Components
Import from `@/components/emcn`, never from subpaths (except CSS files).
## CVA vs Direct Styles
**Use CVA when:** 2+ variants (primary/secondary, sm/md/lg)
```tsx
const buttonVariants = cva('base-classes', {
variants: { variant: { default: '...', primary: '...' } }
})
export { Button, buttonVariants }
```
**Use direct className when:** Single consistent style, no variations
```tsx
function Label({ className, ...props }) {
return <Primitive className={cn('style-classes', className)} {...props} />
}
```
## Rules
- Use Radix UI primitives for accessibility
- Export component and variants (if using CVA)
- TSDoc with usage examples
- Consistent tokens: `font-medium`, `text-[12px]`, `rounded-[4px]`
- `transition-colors` for hover states

View File

@@ -1,13 +0,0 @@
# Global Standards
## Logging
Import `createLogger` from `sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
## Comments
Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
## Styling
Never update global styles. Keep all styling local to components.
## Package Manager
Use `bun` and `bunx`, not `npm` and `npx`.

View File

@@ -1,56 +0,0 @@
---
paths:
- "apps/sim/**"
---
# Sim App Architecture
## Core Principles
1. **Single Responsibility**: Each component, hook, store has one clear purpose
2. **Composition Over Complexity**: Break down complex logic into smaller pieces
3. **Type Safety First**: TypeScript interfaces for all props, state, return types
4. **Predictable State**: Zustand for global state, useState for UI-only concerns
## Root-Level Structure
```
apps/sim/
├── app/ # Next.js app router (pages, API routes)
├── blocks/ # Block definitions and registry
├── components/ # Shared UI (emcn/, ui/)
├── executor/ # Workflow execution engine
├── hooks/ # Shared hooks (queries/, selectors/)
├── lib/ # App-wide utilities
├── providers/ # LLM provider integrations
├── stores/ # Zustand stores
├── tools/ # Tool definitions
└── triggers/ # Trigger definitions
```
## Feature Organization
Features live under `app/workspace/[workspaceId]/`:
```
feature/
├── components/ # Feature components
├── hooks/ # Feature-scoped hooks
├── utils/ # Feature-scoped utilities (2+ consumers)
├── feature.tsx # Main component
└── page.tsx # Next.js page entry
```
## Naming Conventions
- **Components**: PascalCase (`WorkflowList`)
- **Hooks**: `use` prefix (`useWorkflowOperations`)
- **Files**: kebab-case (`workflow-list.tsx`)
- **Stores**: `stores/feature/store.ts`
- **Constants**: SCREAMING_SNAKE_CASE
- **Interfaces**: PascalCase with suffix (`WorkflowListProps`)
## Utils Rules
- **Never create `utils.ts` for single consumer** - inline it
- **Create `utils.ts` when** 2+ files need the same helper
- **Check existing sources** before duplicating (`lib/` has many utilities)
- **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use)

View File

@@ -1,48 +0,0 @@
---
paths:
- "apps/sim/**/*.tsx"
---
# Component Patterns
## Structure Order
```typescript
'use client' // Only if using hooks
// Imports (external → internal)
// Constants at module level
const CONFIG = { SPACING: 8 } as const
// Props interface
interface ComponentProps {
requiredProp: string
optionalProp?: boolean
}
export function Component({ requiredProp, optionalProp = false }: ComponentProps) {
// a. Refs
// b. External hooks (useParams, useRouter)
// c. Store hooks
// d. Custom hooks
// e. Local state
// f. useMemo
// g. useCallback
// h. useEffect
// i. Return JSX
}
```
## Rules
1. `'use client'` only when using React hooks
2. Always define props interface
3. Extract constants with `as const`
4. Semantic HTML (`aside`, `nav`, `article`)
5. Optional chain callbacks: `onAction?.(id)`
## Component Extraction
**Extract when:** 50+ lines, used in 2+ files, or has own state/logic
**Keep inline when:** < 10 lines, single use, purely presentational

View File

@@ -1,55 +0,0 @@
---
paths:
- "apps/sim/**/use-*.ts"
- "apps/sim/**/hooks/**/*.ts"
---
# Hook Patterns
## Structure
```typescript
interface UseFeatureProps {
id: string
onSuccess?: (result: Result) => void
}
export function useFeature({ id, onSuccess }: UseFeatureProps) {
// 1. Refs for stable dependencies
const idRef = useRef(id)
const onSuccessRef = useRef(onSuccess)
// 2. State
const [data, setData] = useState<Data | null>(null)
const [isLoading, setIsLoading] = useState(false)
// 3. Sync refs
useEffect(() => {
idRef.current = id
onSuccessRef.current = onSuccess
}, [id, onSuccess])
// 4. Operations (useCallback with empty deps when using refs)
const fetchData = useCallback(async () => {
setIsLoading(true)
try {
const result = await fetch(`/api/${idRef.current}`).then(r => r.json())
setData(result)
onSuccessRef.current?.(result)
} finally {
setIsLoading(false)
}
}, [])
return { data, isLoading, fetchData }
}
```
## Rules
1. Single responsibility per hook
2. Props interface required
3. Refs for stable callback dependencies
4. Wrap returned functions in useCallback
5. Always try/catch async operations
6. Track loading/error states

View File

@@ -1,62 +0,0 @@
---
paths:
- "apps/sim/**/*.ts"
- "apps/sim/**/*.tsx"
---
# Import Patterns
## Absolute Imports
**Always use absolute imports.** Never use relative imports.
```typescript
// ✓ Good
import { useWorkflowStore } from '@/stores/workflows/store'
import { Button } from '@/components/ui/button'
// ✗ Bad
import { useWorkflowStore } from '../../../stores/workflows/store'
```
## Barrel Exports
Use barrel exports (`index.ts`) when a folder has 3+ exports. Import from barrel, not individual files.
```typescript
// ✓ Good
import { Dashboard, Sidebar } from '@/app/workspace/[workspaceId]/logs/components'
// ✗ Bad
import { Dashboard } from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard'
```
## No Re-exports
Do not re-export from non-barrel files. Import directly from the source.
```typescript
// ✓ Good - import from where it's declared
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
// ✗ Bad - re-exporting in utils.ts then importing from there
import { CORE_TRIGGER_TYPES } from '@/app/workspace/.../utils'
```
## Import Order
1. React/core libraries
2. External libraries
3. UI components (`@/components/emcn`, `@/components/ui`)
4. Utilities (`@/lib/...`)
5. Stores (`@/stores/...`)
6. Feature imports
7. CSS imports
## Type Imports
Use `type` keyword for type-only imports:
```typescript
import type { WorkflowLog } from '@/stores/logs/types'
```

View File

@@ -1,209 +0,0 @@
---
paths:
- "apps/sim/tools/**"
- "apps/sim/blocks/**"
- "apps/sim/triggers/**"
---
# Adding Integrations
## Overview
Adding a new integration typically requires:
1. **Tools** - API operations (`tools/{service}/`)
2. **Block** - UI component (`blocks/blocks/{service}.ts`)
3. **Icon** - SVG icon (`components/icons.tsx`)
4. **Trigger** (optional) - Webhooks/polling (`triggers/{service}/`)
Always look up the service's API docs first.
## 1. Tools (`tools/{service}/`)
```
tools/{service}/
├── index.ts # Export all tools
├── types.ts # Params/response types
├── {action}.ts # Individual tool (e.g., send_message.ts)
└── ...
```
**Tool file structure:**
```typescript
// tools/{service}/{action}.ts
import type { {Service}Params, {Service}Response } from '@/tools/{service}/types'
import type { ToolConfig } from '@/tools/types'
export const {service}{Action}Tool: ToolConfig<{Service}Params, {Service}Response> = {
id: '{service}_{action}',
name: '{Service} {Action}',
description: 'What this tool does',
version: '1.0.0',
oauth: { required: true, provider: '{service}' }, // if OAuth
params: { /* param definitions */ },
request: {
url: '/api/tools/{service}/{action}',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({ ...params }),
},
transformResponse: async (response) => {
const data = await response.json()
if (!data.success) throw new Error(data.error)
return { success: true, output: data.output }
},
outputs: { /* output definitions */ },
}
```
**Register in `tools/registry.ts`:**
```typescript
import { {service}{Action}Tool } from '@/tools/{service}'
// Add to registry object
{service}_{action}: {service}{Action}Tool,
```
## 2. Block (`blocks/blocks/{service}.ts`)
```typescript
import { {Service}Icon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import type { {Service}Response } from '@/tools/{service}/types'
export const {Service}Block: BlockConfig<{Service}Response> = {
type: '{service}',
name: '{Service}',
description: 'Short description',
longDescription: 'Detailed description',
category: 'tools',
bgColor: '#hexcolor',
icon: {Service}Icon,
subBlocks: [ /* see SubBlock Properties below */ ],
tools: {
access: ['{service}_{action}', ...],
config: {
tool: (params) => `{service}_${params.operation}`,
params: (params) => ({ ...params }),
},
},
inputs: { /* input definitions */ },
outputs: { /* output definitions */ },
}
```
### SubBlock Properties
```typescript
{
id: 'fieldName', // Unique identifier
title: 'Field Label', // UI label
type: 'short-input', // See SubBlock Types below
placeholder: 'Hint text',
required: true, // See Required below
condition: { ... }, // See Condition below
dependsOn: ['otherField'], // See DependsOn below
mode: 'basic', // 'basic' | 'advanced' | 'both' | 'trigger'
}
```
**SubBlock Types:** `short-input`, `long-input`, `dropdown`, `code`, `switch`, `slider`, `oauth-input`, `channel-selector`, `user-selector`, `file-upload`, etc.
### `condition` - Show/hide based on another field
```typescript
// Show when operation === 'send'
condition: { field: 'operation', value: 'send' }
// Show when operation is 'send' OR 'read'
condition: { field: 'operation', value: ['send', 'read'] }
// Show when operation !== 'send'
condition: { field: 'operation', value: 'send', not: true }
// Complex: NOT in list AND another condition
condition: {
field: 'operation',
value: ['list_channels', 'list_users'],
not: true,
and: { field: 'destinationType', value: 'dm', not: true }
}
```
### `required` - Field validation
```typescript
// Always required
required: true
// Conditionally required (same syntax as condition)
required: { field: 'operation', value: 'send' }
```
### `dependsOn` - Clear field when dependencies change
```typescript
// Clear when credential changes
dependsOn: ['credential']
// Clear when authMethod changes AND (credential OR botToken) changes
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }
```
### `mode` - When to show field
- `'basic'` - Only in basic mode (default UI)
- `'advanced'` - Only in advanced mode (manual input)
- `'both'` - Show in both modes (default)
- `'trigger'` - Only when block is used as trigger
**Register in `blocks/registry.ts`:**
```typescript
import { {Service}Block } from '@/blocks/blocks/{service}'
// Add to registry object (alphabetically)
{service}: {Service}Block,
```
## 3. Icon (`components/icons.tsx`)
```typescript
export function {Service}Icon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* SVG path from service's brand assets */}
</svg>
)
}
```
## 4. Trigger (`triggers/{service}/`) - Optional
```
triggers/{service}/
├── index.ts # Export all triggers
├── webhook.ts # Webhook handler
├── utils.ts # Shared utilities
└── {event}.ts # Specific event handlers
```
**Register in `triggers/registry.ts`:**
```typescript
import { {service}WebhookTrigger } from '@/triggers/{service}'
// Add to TRIGGER_REGISTRY
{service}_webhook: {service}WebhookTrigger,
```
## Checklist
- [ ] Look up API docs for the service
- [ ] Create `tools/{service}/types.ts` with proper types
- [ ] Create tool files for each operation
- [ ] Create `tools/{service}/index.ts` barrel export
- [ ] Register tools in `tools/registry.ts`
- [ ] Add icon to `components/icons.tsx`
- [ ] Create block in `blocks/blocks/{service}.ts`
- [ ] Register block in `blocks/registry.ts`
- [ ] (Optional) Create triggers in `triggers/{service}/`
- [ ] (Optional) Register triggers in `triggers/registry.ts`

View File

@@ -1,66 +0,0 @@
---
paths:
- "apps/sim/hooks/queries/**/*.ts"
---
# React Query Patterns
All React Query hooks live in `hooks/queries/`.
## Query Key Factory
Every query file defines a keys factory:
```typescript
export const entityKeys = {
all: ['entity'] as const,
list: (workspaceId?: string) => [...entityKeys.all, 'list', workspaceId ?? ''] as const,
detail: (id?: string) => [...entityKeys.all, 'detail', id ?? ''] as const,
}
```
## File Structure
```typescript
// 1. Query keys factory
// 2. Types (if needed)
// 3. Private fetch functions
// 4. Exported hooks
```
## Query Hook
```typescript
export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: entityKeys.list(workspaceId),
queryFn: () => fetchEntities(workspaceId as string),
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
}
```
## Mutation Hook
```typescript
export function useCreateEntity() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (variables) => { /* fetch POST */ },
onSuccess: () => queryClient.invalidateQueries({ queryKey: entityKeys.all }),
})
}
```
## Optimistic Updates
For optimistic mutations syncing with Zustand, use `createOptimisticMutationHandlers` from `@/hooks/queries/utils/optimistic-mutation`.
## Naming
- **Keys**: `entityKeys`
- **Query hooks**: `useEntity`, `useEntityList`
- **Mutation hooks**: `useCreateEntity`, `useUpdateEntity`
- **Fetch functions**: `fetchEntity` (private)

View File

@@ -1,71 +0,0 @@
---
paths:
- "apps/sim/**/store.ts"
- "apps/sim/**/stores/**/*.ts"
---
# Zustand Store Patterns
Stores live in `stores/`. Complex stores split into `store.ts` + `types.ts`.
## Basic Store
```typescript
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import type { FeatureState } from '@/stores/feature/types'
const initialState = { items: [] as Item[], activeId: null as string | null }
export const useFeatureStore = create<FeatureState>()(
devtools(
(set, get) => ({
...initialState,
setItems: (items) => set({ items }),
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
reset: () => set(initialState),
}),
{ name: 'feature-store' }
)
)
```
## Persisted Store
```typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useFeatureStore = create<FeatureState>()(
persist(
(set) => ({
width: 300,
setWidth: (width) => set({ width }),
_hasHydrated: false,
setHasHydrated: (v) => set({ _hasHydrated: v }),
}),
{
name: 'feature-state',
partialize: (state) => ({ width: state.width }),
onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
}
)
)
```
## Rules
1. Use `devtools` middleware (named stores)
2. Use `persist` only when data should survive reload
3. `partialize` to persist only necessary state
4. `_hasHydrated` pattern for persisted stores needing hydration tracking
5. Immutable updates only
6. `set((state) => ...)` when depending on previous state
7. Provide `reset()` action
## Outside React
```typescript
const items = useFeatureStore.getState().items
useFeatureStore.setState({ items: newItems })
```

View File

@@ -1,41 +0,0 @@
---
paths:
- "apps/sim/**/*.tsx"
- "apps/sim/**/*.css"
---
# Styling Rules
## Tailwind
1. **No inline styles** - Use Tailwind classes
2. **No duplicate dark classes** - Skip `dark:` when value matches light mode
3. **Exact values** - `text-[14px]`, `h-[26px]`
4. **Transitions** - `transition-colors` for interactive states
## Conditional Classes
```typescript
import { cn } from '@/lib/utils'
<div className={cn(
'base-classes',
isActive && 'active-classes',
disabled ? 'opacity-60' : 'hover:bg-accent'
)} />
```
## CSS Variables
For dynamic values (widths, heights) synced with stores:
```typescript
// In store
setWidth: (width) => {
set({ width })
document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
}
// In component
<aside style={{ width: 'var(--sidebar-width)' }} />
```

View File

@@ -1,58 +0,0 @@
---
paths:
- "apps/sim/**/*.test.ts"
- "apps/sim/**/*.test.tsx"
---
# Testing Patterns
Use Vitest. Test files: `feature.ts``feature.test.ts`
## Structure
```typescript
/**
* @vitest-environment node
*/
import { databaseMock, loggerMock } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => databaseMock)
vi.mock('@sim/logger', () => loggerMock)
import { myFunction } from '@/lib/feature'
describe('myFunction', () => {
beforeEach(() => vi.clearAllMocks())
it.concurrent('isolated tests run in parallel', () => { ... })
})
```
## @sim/testing Package
Always prefer over local mocks.
| Category | Utilities |
|----------|-----------|
| **Mocks** | `loggerMock`, `databaseMock`, `setupGlobalFetchMock()` |
| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutorContext()` |
| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` |
| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` |
## Rules
1. `@vitest-environment node` directive at file top
2. `vi.mock()` calls before importing mocked modules
3. `@sim/testing` utilities over local mocks
4. `it.concurrent` for isolated tests (no shared mutable state)
5. `beforeEach(() => vi.clearAllMocks())` to reset state
## Hoisted Mocks
For mutable mock references:
```typescript
const mockFn = vi.hoisted(() => vi.fn())
vi.mock('@/lib/module', () => ({ myFunction: mockFn }))
mockFn.mockResolvedValue({ data: 'test' })
```

View File

@@ -1,21 +0,0 @@
---
paths:
- "apps/sim/**/*.ts"
- "apps/sim/**/*.tsx"
---
# TypeScript Rules
1. **No `any`** - Use proper types or `unknown` with type guards
2. **Props interface** - Always define for components
3. **Const assertions** - `as const` for constant objects/arrays
4. **Ref types** - Explicit: `useRef<HTMLDivElement>(null)`
5. **Type imports** - `import type { X }` for type-only imports
```typescript
// ✗ Bad
const handleClick = (e: any) => {}
// ✓ Good
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {}
```

File diff suppressed because it is too large Load Diff

View File

@@ -119,145 +119,6 @@ Get a specific event from Google Calendar. Returns API-aligned fields only.
| `creator` | json | Event creator |
| `organizer` | json | Event organizer |
### `google_calendar_update`
Update an existing event in Google Calendar. Returns API-aligned fields only.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
| `eventId` | string | Yes | Event ID to update |
| `summary` | string | No | New event title/summary |
| `description` | string | No | New event description |
| `location` | string | No | New event location |
| `startDateTime` | string | No | New start date and time. MUST include timezone offset \(e.g., 2025-06-03T10:00:00-08:00\) OR provide timeZone parameter |
| `endDateTime` | string | No | New end date and time. MUST include timezone offset \(e.g., 2025-06-03T11:00:00-08:00\) OR provide timeZone parameter |
| `timeZone` | string | No | Time zone \(e.g., America/Los_Angeles\). Required if datetime does not include offset. |
| `attendees` | array | No | Array of attendee email addresses \(replaces existing attendees\) |
| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Event ID |
| `htmlLink` | string | Event link |
| `status` | string | Event status |
| `summary` | string | Event title |
| `description` | string | Event description |
| `location` | string | Event location |
| `start` | json | Event start |
| `end` | json | Event end |
| `attendees` | json | Event attendees |
| `creator` | json | Event creator |
| `organizer` | json | Event organizer |
### `google_calendar_delete`
Delete an event from Google Calendar. Returns API-aligned fields only.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
| `eventId` | string | Yes | Event ID to delete |
| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `eventId` | string | Deleted event ID |
| `deleted` | boolean | Whether deletion was successful |
### `google_calendar_move`
Move an event to a different calendar. Returns API-aligned fields only.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `calendarId` | string | No | Source calendar ID \(defaults to primary\) |
| `eventId` | string | Yes | Event ID to move |
| `destinationCalendarId` | string | Yes | Destination calendar ID |
| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Event ID |
| `htmlLink` | string | Event link |
| `status` | string | Event status |
| `summary` | string | Event title |
| `description` | string | Event description |
| `location` | string | Event location |
| `start` | json | Event start |
| `end` | json | Event end |
| `attendees` | json | Event attendees |
| `creator` | json | Event creator |
| `organizer` | json | Event organizer |
### `google_calendar_instances`
Get instances of a recurring event from Google Calendar. Returns API-aligned fields only.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
| `eventId` | string | Yes | Recurring event ID to get instances of |
| `timeMin` | string | No | Lower bound for instances \(RFC3339 timestamp, e.g., 2025-06-03T00:00:00Z\) |
| `timeMax` | string | No | Upper bound for instances \(RFC3339 timestamp, e.g., 2025-06-04T00:00:00Z\) |
| `maxResults` | number | No | Maximum number of instances to return \(default 250, max 2500\) |
| `pageToken` | string | No | Token for retrieving subsequent pages of results |
| `showDeleted` | boolean | No | Include deleted instances |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `nextPageToken` | string | Next page token |
| `timeZone` | string | Calendar time zone |
| `instances` | json | List of recurring event instances |
### `google_calendar_list_calendars`
List all calendars in the user
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `minAccessRole` | string | No | Minimum access role for returned calendars: freeBusyReader, reader, writer, or owner |
| `maxResults` | number | No | Maximum number of calendars to return \(default 100, max 250\) |
| `pageToken` | string | No | Token for retrieving subsequent pages of results |
| `showDeleted` | boolean | No | Include deleted calendars |
| `showHidden` | boolean | No | Include hidden calendars |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `nextPageToken` | string | Next page token |
| `calendars` | array | List of calendars |
| ↳ `id` | string | Calendar ID |
| ↳ `summary` | string | Calendar title |
| ↳ `description` | string | Calendar description |
| ↳ `location` | string | Calendar location |
| ↳ `timeZone` | string | Calendar time zone |
| ↳ `accessRole` | string | Access role for the calendar |
| ↳ `backgroundColor` | string | Calendar background color |
| ↳ `foregroundColor` | string | Calendar foreground color |
| ↳ `primary` | boolean | Whether this is the primary calendar |
| ↳ `hidden` | boolean | Whether the calendar is hidden |
| ↳ `selected` | boolean | Whether the calendar is selected |
### `google_calendar_quick_add`
Create events from natural language text. Returns API-aligned fields only.

View File

@@ -1,6 +1,6 @@
---
title: Google Drive
description: Manage files, folders, and permissions
description: Create, upload, and list files
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -40,179 +40,12 @@ In Sim, the Google Drive integration enables your agents to interact directly wi
## Usage Instructions
Integrate Google Drive into the workflow. Can create, upload, download, copy, move, delete, share files and manage permissions.
Integrate Google Drive into the workflow. Can create, upload, and list files.
## Tools
### `google_drive_list`
List files and folders in Google Drive with complete metadata
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `folderSelector` | string | No | Select the folder to list files from |
| `folderId` | string | No | The ID of the folder to list files from \(internal use\) |
| `query` | string | No | Search term to filter files by name \(e.g. "budget" finds files with "budget" in the name\). Do NOT use Google Drive query syntax here - just provide a plain search term. |
| `pageSize` | number | No | The maximum number of files to return \(default: 100\) |
| `pageToken` | string | No | The page token to use for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `files` | array | Array of file metadata objects from Google Drive |
| ↳ `id` | string | Google Drive file ID |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `description` | string | File description |
| ↳ `originalFilename` | string | Original uploaded filename |
| ↳ `fullFileExtension` | string | Full file extension |
| ↳ `fileExtension` | string | File extension |
| ↳ `owners` | json | List of file owners |
| ↳ `permissions` | json | File permissions |
| ↳ `permissionIds` | json | Permission IDs |
| ↳ `shared` | boolean | Whether file is shared |
| ↳ `ownedByMe` | boolean | Whether owned by current user |
| ↳ `writersCanShare` | boolean | Whether writers can share |
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
| ↳ `sharingUser` | json | User who shared the file |
| ↳ `starred` | boolean | Whether file is starred |
| ↳ `trashed` | boolean | Whether file is in trash |
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
| ↳ `appProperties` | json | App-specific properties |
| ↳ `createdTime` | string | File creation time |
| ↳ `modifiedTime` | string | Last modification time |
| ↳ `modifiedByMeTime` | string | When modified by current user |
| ↳ `viewedByMeTime` | string | When last viewed by current user |
| ↳ `sharedWithMeTime` | string | When shared with current user |
| ↳ `lastModifyingUser` | json | User who last modified the file |
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
| ↳ `webViewLink` | string | URL to view in browser |
| ↳ `webContentLink` | string | Direct download URL |
| ↳ `iconLink` | string | URL to file icon |
| ↳ `thumbnailLink` | string | URL to thumbnail |
| ↳ `exportLinks` | json | Export format links |
| ↳ `size` | string | File size in bytes |
| ↳ `quotaBytesUsed` | string | Storage quota used |
| ↳ `md5Checksum` | string | MD5 hash |
| ↳ `sha1Checksum` | string | SHA-1 hash |
| ↳ `sha256Checksum` | string | SHA-256 hash |
| ↳ `parents` | json | Parent folder IDs |
| ↳ `spaces` | json | Spaces containing file |
| ↳ `driveId` | string | Shared drive ID |
| ↳ `capabilities` | json | User capabilities on file |
| ↳ `version` | string | Version number |
| ↳ `headRevisionId` | string | Head revision ID |
| ↳ `hasThumbnail` | boolean | Whether has thumbnail |
| ↳ `thumbnailVersion` | string | Thumbnail version |
| ↳ `imageMediaMetadata` | json | Image-specific metadata |
| ↳ `videoMediaMetadata` | json | Video-specific metadata |
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
| ↳ `contentRestrictions` | json | Content restrictions |
| ↳ `linkShareMetadata` | json | Link share metadata |
| `nextPageToken` | string | Token for fetching the next page of results |
### `google_drive_get_file`
Get metadata for a specific file in Google Drive by its ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | json | The file metadata |
| ↳ `id` | string | Google Drive file ID |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `description` | string | File description |
| ↳ `size` | string | File size in bytes |
| ↳ `starred` | boolean | Whether file is starred |
| ↳ `trashed` | boolean | Whether file is in trash |
| ↳ `webViewLink` | string | URL to view in browser |
| ↳ `webContentLink` | string | Direct download URL |
| ↳ `iconLink` | string | URL to file icon |
| ↳ `thumbnailLink` | string | URL to thumbnail |
| ↳ `parents` | json | Parent folder IDs |
| ↳ `owners` | json | List of file owners |
| ↳ `permissions` | json | File permissions |
| ↳ `createdTime` | string | File creation time |
| ↳ `modifiedTime` | string | Last modification time |
| ↳ `lastModifyingUser` | json | User who last modified the file |
| ↳ `shared` | boolean | Whether file is shared |
| ↳ `ownedByMe` | boolean | Whether owned by current user |
| ↳ `capabilities` | json | User capabilities on file |
| ↳ `md5Checksum` | string | MD5 hash |
| ↳ `version` | string | Version number |
### `google_drive_create_folder`
Create a new folder in Google Drive with complete metadata returned
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileName` | string | Yes | Name of the folder to create |
| `folderSelector` | string | No | Select the parent folder to create the folder in |
| `folderId` | string | No | ID of the parent folder \(internal use\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | object | Complete created folder metadata from Google Drive |
| ↳ `id` | string | Google Drive folder ID |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | Folder name |
| ↳ `mimeType` | string | MIME type \(application/vnd.google-apps.folder\) |
| ↳ `description` | string | Folder description |
| ↳ `owners` | json | List of folder owners |
| ↳ `permissions` | json | Folder permissions |
| ↳ `permissionIds` | json | Permission IDs |
| ↳ `shared` | boolean | Whether folder is shared |
| ↳ `ownedByMe` | boolean | Whether owned by current user |
| ↳ `writersCanShare` | boolean | Whether writers can share |
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
| ↳ `sharingUser` | json | User who shared the folder |
| ↳ `starred` | boolean | Whether folder is starred |
| ↳ `trashed` | boolean | Whether folder is in trash |
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
| ↳ `appProperties` | json | App-specific properties |
| ↳ `folderColorRgb` | string | Folder color |
| ↳ `createdTime` | string | Folder creation time |
| ↳ `modifiedTime` | string | Last modification time |
| ↳ `modifiedByMeTime` | string | When modified by current user |
| ↳ `viewedByMeTime` | string | When last viewed by current user |
| ↳ `sharedWithMeTime` | string | When shared with current user |
| ↳ `lastModifyingUser` | json | User who last modified the folder |
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
| ↳ `webViewLink` | string | URL to view in browser |
| ↳ `iconLink` | string | URL to folder icon |
| ↳ `parents` | json | Parent folder IDs |
| ↳ `spaces` | json | Spaces containing folder |
| ↳ `driveId` | string | Shared drive ID |
| ↳ `capabilities` | json | User capabilities on folder |
| ↳ `version` | string | Version number |
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
| ↳ `contentRestrictions` | json | Content restrictions |
| ↳ `linkShareMetadata` | json | Link share metadata |
### `google_drive_upload`
Upload a file to Google Drive with complete metadata returned
@@ -234,9 +67,9 @@ Upload a file to Google Drive with complete metadata returned
| --------- | ---- | ----------- |
| `file` | object | Complete uploaded file metadata from Google Drive |
| ↳ `id` | string | Google Drive file ID |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `kind` | string | Resource type identifier |
| ↳ `description` | string | File description |
| ↳ `originalFilename` | string | Original uploaded filename |
| ↳ `fullFileExtension` | string | Full file extension |
@@ -286,6 +119,61 @@ Upload a file to Google Drive with complete metadata returned
| ↳ `contentRestrictions` | json | Content restrictions |
| ↳ `linkShareMetadata` | json | Link share metadata |
### `google_drive_create_folder`
Create a new folder in Google Drive with complete metadata returned
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileName` | string | Yes | Name of the folder to create |
| `folderSelector` | string | No | Select the parent folder to create the folder in |
| `folderId` | string | No | ID of the parent folder \(internal use\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | object | Complete created folder metadata from Google Drive |
| ↳ `id` | string | Google Drive folder ID |
| ↳ `name` | string | Folder name |
| ↳ `mimeType` | string | MIME type \(application/vnd.google-apps.folder\) |
| ↳ `kind` | string | Resource type identifier |
| ↳ `description` | string | Folder description |
| ↳ `owners` | json | List of folder owners |
| ↳ `permissions` | json | Folder permissions |
| ↳ `permissionIds` | json | Permission IDs |
| ↳ `shared` | boolean | Whether folder is shared |
| ↳ `ownedByMe` | boolean | Whether owned by current user |
| ↳ `writersCanShare` | boolean | Whether writers can share |
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
| ↳ `sharingUser` | json | User who shared the folder |
| ↳ `starred` | boolean | Whether folder is starred |
| ↳ `trashed` | boolean | Whether folder is in trash |
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
| ↳ `appProperties` | json | App-specific properties |
| ↳ `folderColorRgb` | string | Folder color |
| ↳ `createdTime` | string | Folder creation time |
| ↳ `modifiedTime` | string | Last modification time |
| ↳ `modifiedByMeTime` | string | When modified by current user |
| ↳ `viewedByMeTime` | string | When last viewed by current user |
| ↳ `sharedWithMeTime` | string | When shared with current user |
| ↳ `lastModifyingUser` | json | User who last modified the folder |
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
| ↳ `webViewLink` | string | URL to view in browser |
| ↳ `iconLink` | string | URL to folder icon |
| ↳ `parents` | json | Parent folder IDs |
| ↳ `spaces` | json | Spaces containing folder |
| ↳ `driveId` | string | Shared drive ID |
| ↳ `capabilities` | json | User capabilities on folder |
| ↳ `version` | string | Version number |
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
| ↳ `contentRestrictions` | json | Content restrictions |
| ↳ `linkShareMetadata` | json | Link share metadata |
### `google_drive_download`
Download a file from Google Drive with complete metadata (exports Google Workspace files automatically)
@@ -310,9 +198,9 @@ Download a file from Google Drive with complete metadata (exports Google Workspa
| ↳ `size` | number | File size in bytes |
| `metadata` | object | Complete file metadata from Google Drive |
| ↳ `id` | string | Google Drive file ID |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `kind` | string | Resource type identifier |
| ↳ `description` | string | File description |
| ↳ `originalFilename` | string | Original uploaded filename |
| ↳ `fullFileExtension` | string | Full file extension |
@@ -363,211 +251,77 @@ Download a file from Google Drive with complete metadata (exports Google Workspa
| ↳ `linkShareMetadata` | json | Link share metadata |
| ↳ `revisions` | json | File revision history \(first 100 revisions only\) |
### `google_drive_copy`
### `google_drive_list`
Create a copy of a file in Google Drive
List files and folders in Google Drive with complete metadata
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to copy |
| `newName` | string | No | Name for the copied file \(defaults to "Copy of \[original name\]"\) |
| `destinationFolderId` | string | No | ID of the folder to place the copy in \(defaults to same location as original\) |
| `folderSelector` | string | No | Select the folder to list files from |
| `folderId` | string | No | The ID of the folder to list files from \(internal use\) |
| `query` | string | No | Search term to filter files by name \(e.g. "budget" finds files with "budget" in the name\). Do NOT use Google Drive query syntax here - just provide a plain search term. |
| `pageSize` | number | No | The maximum number of files to return \(default: 100\) |
| `pageToken` | string | No | The page token to use for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | json | The copied file metadata |
| ↳ `id` | string | Google Drive file ID of the copy |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `webViewLink` | string | URL to view in browser |
| ↳ `parents` | json | Parent folder IDs |
| ↳ `createdTime` | string | File creation time |
| ↳ `modifiedTime` | string | Last modification time |
| ↳ `owners` | json | List of file owners |
| ↳ `size` | string | File size in bytes |
### `google_drive_update`
Update file metadata in Google Drive (rename, move, star, add description)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to update |
| `name` | string | No | New name for the file |
| `description` | string | No | New description for the file |
| `addParents` | string | No | Comma-separated list of parent folder IDs to add \(moves file to these folders\) |
| `removeParents` | string | No | Comma-separated list of parent folder IDs to remove |
| `starred` | boolean | No | Whether to star or unstar the file |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | json | The updated file metadata |
| ↳ `id` | string | Google Drive file ID |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `description` | string | File description |
| ↳ `starred` | boolean | Whether file is starred |
| ↳ `webViewLink` | string | URL to view in browser |
| ↳ `parents` | json | Parent folder IDs |
| ↳ `modifiedTime` | string | Last modification time |
### `google_drive_trash`
Move a file to the trash in Google Drive (can be restored later)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to move to trash |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | json | The trashed file metadata |
| ↳ `id` | string | Google Drive file ID |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `trashed` | boolean | Whether file is in trash \(should be true\) |
| ↳ `trashedTime` | string | When file was trashed |
| ↳ `webViewLink` | string | URL to view in browser |
### `google_drive_delete`
Permanently delete a file from Google Drive (bypasses trash)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to permanently delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the file was successfully deleted |
| `fileId` | string | The ID of the deleted file |
### `google_drive_share`
Share a file with a user, group, domain, or make it public
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to share |
| `type` | string | Yes | Type of grantee: user, group, domain, or anyone |
| `role` | string | Yes | Permission role: owner \(transfer ownership\), organizer \(shared drive only\), fileOrganizer \(shared drive only\), writer \(edit\), commenter \(view and comment\), reader \(view only\) |
| `email` | string | No | Email address of the user or group \(required for type=user or type=group\) |
| `domain` | string | No | Domain to share with \(required for type=domain\) |
| `transferOwnership` | boolean | No | Required when role is owner. Transfers ownership to the specified user. |
| `moveToNewOwnersRoot` | boolean | No | When transferring ownership, move the file to the new owner's My Drive root folder. |
| `sendNotification` | boolean | No | Whether to send an email notification \(default: true\) |
| `emailMessage` | string | No | Custom message to include in the notification email |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `permission` | json | The created permission details |
| ↳ `id` | string | Permission ID |
| ↳ `type` | string | Grantee type \(user, group, domain, anyone\) |
| ↳ `role` | string | Permission role |
| ↳ `emailAddress` | string | Email of the grantee |
| ↳ `displayName` | string | Display name of the grantee |
| ↳ `domain` | string | Domain of the grantee |
| ↳ `expirationTime` | string | Expiration time |
| ↳ `deleted` | boolean | Whether grantee is deleted |
### `google_drive_unshare`
Remove a permission from a file (revoke access)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to modify permissions on |
| `permissionId` | string | Yes | The ID of the permission to remove \(use list_permissions to find this\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `removed` | boolean | Whether the permission was successfully removed |
| `fileId` | string | The ID of the file |
| `permissionId` | string | The ID of the removed permission |
### `google_drive_list_permissions`
List all permissions (who has access) for a file in Google Drive
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to list permissions for |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `permissions` | array | List of permissions on the file |
| ↳ `id` | string | Permission ID \(use to remove permission\) |
| ↳ `type` | string | Grantee type \(user, group, domain, anyone\) |
| ↳ `role` | string | Permission role \(owner, organizer, fileOrganizer, writer, commenter, reader\) |
| ↳ `emailAddress` | string | Email of the grantee |
| ↳ `displayName` | string | Display name of the grantee |
| ↳ `photoLink` | string | Photo URL of the grantee |
| ↳ `domain` | string | Domain of the grantee |
| ↳ `expirationTime` | string | When permission expires |
| ↳ `deleted` | boolean | Whether grantee account is deleted |
| ↳ `allowFileDiscovery` | boolean | Whether file is discoverable by grantee |
| ↳ `pendingOwner` | boolean | Whether ownership transfer is pending |
| ↳ `permissionDetails` | json | Details about inherited permissions |
| `nextPageToken` | string | Token for fetching the next page of permissions |
### `google_drive_get_about`
Get information about the user and their Google Drive (storage quota, capabilities)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `user` | json | Information about the authenticated user |
| ↳ `displayName` | string | User display name |
| ↳ `emailAddress` | string | User email address |
| ↳ `photoLink` | string | URL to user profile photo |
| ↳ `permissionId` | string | User permission ID |
| ↳ `me` | boolean | Whether this is the authenticated user |
| `storageQuota` | json | Storage quota information in bytes |
| ↳ `limit` | string | Total storage limit in bytes \(null for unlimited\) |
| ↳ `usage` | string | Total storage used in bytes |
| ↳ `usageInDrive` | string | Storage used by Drive files in bytes |
| ↳ `usageInDriveTrash` | string | Storage used by trashed files in bytes |
| `canCreateDrives` | boolean | Whether user can create shared drives |
| `importFormats` | json | Map of MIME types that can be imported and their target formats |
| `exportFormats` | json | Map of Google Workspace MIME types and their exportable formats |
| `maxUploadSize` | string | Maximum upload size in bytes |
| `files` | array | Array of file metadata objects from Google Drive |
| ↳ `id` | string | Google Drive file ID |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `kind` | string | Resource type identifier |
| ↳ `description` | string | File description |
| ↳ `originalFilename` | string | Original uploaded filename |
| ↳ `fullFileExtension` | string | Full file extension |
| ↳ `fileExtension` | string | File extension |
| ↳ `owners` | json | List of file owners |
| ↳ `permissions` | json | File permissions |
| ↳ `permissionIds` | json | Permission IDs |
| ↳ `shared` | boolean | Whether file is shared |
| ↳ `ownedByMe` | boolean | Whether owned by current user |
| ↳ `writersCanShare` | boolean | Whether writers can share |
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
| ↳ `sharingUser` | json | User who shared the file |
| ↳ `starred` | boolean | Whether file is starred |
| ↳ `trashed` | boolean | Whether file is in trash |
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
| ↳ `appProperties` | json | App-specific properties |
| ↳ `createdTime` | string | File creation time |
| ↳ `modifiedTime` | string | Last modification time |
| ↳ `modifiedByMeTime` | string | When modified by current user |
| ↳ `viewedByMeTime` | string | When last viewed by current user |
| ↳ `sharedWithMeTime` | string | When shared with current user |
| ↳ `lastModifyingUser` | json | User who last modified the file |
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
| ↳ `webViewLink` | string | URL to view in browser |
| ↳ `webContentLink` | string | Direct download URL |
| ↳ `iconLink` | string | URL to file icon |
| ↳ `thumbnailLink` | string | URL to thumbnail |
| ↳ `exportLinks` | json | Export format links |
| ↳ `size` | string | File size in bytes |
| ↳ `quotaBytesUsed` | string | Storage quota used |
| ↳ `md5Checksum` | string | MD5 hash |
| ↳ `sha1Checksum` | string | SHA-1 hash |
| ↳ `sha256Checksum` | string | SHA-256 hash |
| ↳ `parents` | json | Parent folder IDs |
| ↳ `spaces` | json | Spaces containing file |
| ↳ `driveId` | string | Shared drive ID |
| ↳ `capabilities` | json | User capabilities on file |
| ↳ `version` | string | Version number |
| ↳ `headRevisionId` | string | Head revision ID |
| ↳ `hasThumbnail` | boolean | Whether has thumbnail |
| ↳ `thumbnailVersion` | string | Thumbnail version |
| ↳ `imageMediaMetadata` | json | Image-specific metadata |
| ↳ `videoMediaMetadata` | json | Video-specific metadata |
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
| ↳ `contentRestrictions` | json | Content restrictions |
| ↳ `linkShareMetadata` | json | Link share metadata |
| `nextPageToken` | string | Token for fetching the next page of results |

View File

@@ -1,6 +1,6 @@
---
title: Google Forms
description: Manage Google Forms and responses
description: Read responses from a Google Form
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -29,7 +29,7 @@ In Sim, the Google Forms integration enables your agents to programmatically acc
## Usage Instructions
Integrate Google Forms into your workflow. Read form structure, get responses, create forms, update content, and manage notification watches.
Integrate Google Forms into your workflow. Provide a Form ID to list responses, or specify a Response ID to fetch a single response. Requires OAuth.
@@ -37,246 +37,15 @@ Integrate Google Forms into your workflow. Read form structure, get responses, c
### `google_forms_get_responses`
Retrieve a single response or list responses from a Google Form
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form |
| `responseId` | string | No | If provided, returns this specific response |
| `pageSize` | number | No | Maximum number of responses to return \(service may return fewer\). Defaults to 5000. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `responses` | array | Array of form responses \(when no responseId provided\) |
| ↳ `responseId` | string | Unique response ID |
| ↳ `createTime` | string | When the response was created |
| ↳ `lastSubmittedTime` | string | When the response was last submitted |
| ↳ `answers` | json | Map of question IDs to answer values |
| `response` | object | Single form response \(when responseId is provided\) |
| ↳ `responseId` | string | Unique response ID |
| ↳ `createTime` | string | When the response was created |
| ↳ `lastSubmittedTime` | string | When the response was last submitted |
| ↳ `answers` | json | Map of question IDs to answer values |
| `raw` | json | Raw API response data |
### `google_forms_get_form`
Retrieve a form structure including its items, settings, and metadata
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `formId` | string | The form ID |
| `title` | string | The form title visible to responders |
| `description` | string | The form description |
| `documentTitle` | string | The document title visible in Drive |
| `responderUri` | string | The URI to share with responders |
| `linkedSheetId` | string | The ID of the linked Google Sheet |
| `revisionId` | string | The revision ID of the form |
| `items` | array | The form items \(questions, sections, etc.\) |
| ↳ `itemId` | string | Item ID |
| ↳ `title` | string | Item title |
| ↳ `description` | string | Item description |
| `settings` | json | Form settings |
| `publishSettings` | json | Form publish settings |
### `google_forms_create_form`
Create a new Google Form with a title
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `title` | string | Yes | The title of the form visible to responders |
| `documentTitle` | string | No | The document title visible in Drive \(defaults to form title\) |
| `unpublished` | boolean | No | If true, create an unpublished form that does not accept responses |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `formId` | string | The ID of the created form |
| `title` | string | The form title |
| `documentTitle` | string | The document title in Drive |
| `responderUri` | string | The URI to share with responders |
| `revisionId` | string | The revision ID of the form |
### `google_forms_batch_update`
Apply multiple updates to a form (add items, update info, change settings, etc.)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form to update |
| `requests` | json | Yes | Array of update requests \(updateFormInfo, updateSettings, createItem, updateItem, moveItem, deleteItem\) |
| `includeFormInResponse` | boolean | No | Whether to return the updated form in the response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `replies` | array | The replies from each update request |
| `writeControl` | object | Write control information with revision IDs |
| ↳ `requiredRevisionId` | string | Required revision ID for conflict detection |
| ↳ `targetRevisionId` | string | Target revision ID |
| `form` | object | The updated form \(if includeFormInResponse was true\) |
| ↳ `formId` | string | The form ID |
| ↳ `info` | object | Form info containing title and description |
| ↳ `title` | string | The form title visible to responders |
| ↳ `description` | string | The form description |
| ↳ `documentTitle` | string | The document title visible in Drive |
| ↳ `title` | string | Item title |
| ↳ `description` | string | Item description |
| ↳ `documentTitle` | string | The document title visible in Drive |
| ↳ `settings` | object | Form settings |
| ↳ `quizSettings` | object | Quiz settings |
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
| ↳ `emailCollectionType` | string | Email collection type |
| ↳ `quizSettings` | object | Quiz settings |
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
| ↳ `emailCollectionType` | string | Email collection type |
| ↳ `itemId` | string | Item ID |
| ↳ `questionItem` | json | Question item configuration |
| ↳ `questionGroupItem` | json | Question group configuration |
| ↳ `pageBreakItem` | json | Page break configuration |
| ↳ `textItem` | json | Text item configuration |
| ↳ `imageItem` | json | Image item configuration |
| ↳ `videoItem` | json | Video item configuration |
| ↳ `revisionId` | string | The revision ID of the form |
| ↳ `responderUri` | string | The URI to share with responders |
| ↳ `linkedSheetId` | string | The ID of the linked Google Sheet |
| ↳ `publishSettings` | object | Form publish settings |
| ↳ `publishState` | object | Current publish state |
| ↳ `isPublished` | boolean | Whether the form is published |
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
| ↳ `isPublished` | boolean | Whether the form is published |
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
| ↳ `publishState` | object | Current publish state |
| ↳ `isPublished` | boolean | Whether the form is published |
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
| ↳ `isPublished` | boolean | Whether the form is published |
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
### `google_forms_set_publish_settings`
Update the publish settings of a form (publish/unpublish, accept responses)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form |
| `isPublished` | boolean | Yes | Whether the form is published and visible to others |
| `isAcceptingResponses` | boolean | No | Whether the form accepts responses \(forced to false if isPublished is false\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `formId` | string | The form ID |
| `publishSettings` | json | The updated publish settings |
| ↳ `publishState` | object | The publish state |
| ↳ `isPublished` | boolean | Whether the form is published |
| ↳ `isAcceptingResponses` | boolean | Whether the form accepts responses |
| ↳ `isPublished` | boolean | Whether the form is published |
| ↳ `isAcceptingResponses` | boolean | Whether the form accepts responses |
### `google_forms_create_watch`
Create a notification watch for form changes (schema changes or new responses)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form to watch |
| `eventType` | string | Yes | Event type to watch: SCHEMA \(form changes\) or RESPONSES \(new submissions\) |
| `topicName` | string | Yes | The Cloud Pub/Sub topic name \(format: projects/\{project\}/topics/\{topic\}\) |
| `watchId` | string | No | Custom watch ID \(4-63 chars, lowercase letters, numbers, hyphens\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | The watch ID |
| `eventType` | string | The event type being watched |
| `topicName` | string | The Cloud Pub/Sub topic |
| `createTime` | string | When the watch was created |
| `expireTime` | string | When the watch expires \(7 days after creation\) |
| `state` | string | The watch state \(ACTIVE, SUSPENDED\) |
### `google_forms_list_watches`
List all notification watches for a form
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `watches` | array | List of watches for the form |
| ↳ `id` | string | Watch ID |
| ↳ `eventType` | string | Event type \(SCHEMA or RESPONSES\) |
| ↳ `createTime` | string | When the watch was created |
| ↳ `expireTime` | string | When the watch expires |
| ↳ `state` | string | Watch state |
### `google_forms_delete_watch`
Delete a notification watch from a form
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form |
| `watchId` | string | Yes | The ID of the watch to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the watch was successfully deleted |
### `google_forms_renew_watch`
Renew a notification watch for another 7 days
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form |
| `watchId` | string | Yes | The ID of the watch to renew |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | The watch ID |
| `eventType` | string | The event type being watched |
| `expireTime` | string | The new expiration time |
| `state` | string | The watch state |
| `data` | json | Response or list of responses |

View File

@@ -215,191 +215,4 @@ Check if a user is a member of a Google Group
| --------- | ---- | ----------- |
| `isMember` | boolean | Whether the user is a member of the group |
### `google_groups_list_aliases`
List all email aliases for a Google Group
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupKey` | string | Yes | Group email address or unique group ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `aliases` | array | List of email aliases for the group |
| ↳ `id` | string | Unique group identifier |
| ↳ `primaryEmail` | string | Group |
| ↳ `alias` | string | Alias email address |
| ↳ `kind` | string | API resource type |
| ↳ `etag` | string | Resource version identifier |
### `google_groups_add_alias`
Add an email alias to a Google Group
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupKey` | string | Yes | Group email address or unique group ID |
| `alias` | string | Yes | The email alias to add to the group |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Unique group identifier |
| `primaryEmail` | string | Group |
| `alias` | string | The alias that was added |
| `kind` | string | API resource type |
| `etag` | string | Resource version identifier |
### `google_groups_remove_alias`
Remove an email alias from a Google Group
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupKey` | string | Yes | Group email address or unique group ID |
| `alias` | string | Yes | The email alias to remove from the group |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the alias was successfully deleted |
### `google_groups_get_settings`
Get the settings for a Google Group including access permissions, moderation, and posting options
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupEmail` | string | Yes | The email address of the group |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `email` | string | The group |
| `name` | string | The group name \(max 75 characters\) |
| `description` | string | The group description \(max 4096 characters\) |
| `whoCanJoin` | string | Who can join the group \(ANYONE_CAN_JOIN, ALL_IN_DOMAIN_CAN_JOIN, INVITED_CAN_JOIN, CAN_REQUEST_TO_JOIN\) |
| `whoCanViewMembership` | string | Who can view group membership |
| `whoCanViewGroup` | string | Who can view group messages |
| `whoCanPostMessage` | string | Who can post messages to the group |
| `allowExternalMembers` | string | Whether external users can be members |
| `allowWebPosting` | string | Whether web posting is allowed |
| `primaryLanguage` | string | The group |
| `isArchived` | string | Whether messages are archived |
| `archiveOnly` | string | Whether the group is archive-only \(inactive\) |
| `messageModerationLevel` | string | Message moderation level |
| `spamModerationLevel` | string | Spam handling level \(ALLOW, MODERATE, SILENTLY_MODERATE, REJECT\) |
| `replyTo` | string | Default reply destination |
| `customReplyTo` | string | Custom email for replies |
| `includeCustomFooter` | string | Whether to include custom footer |
| `customFooterText` | string | Custom footer text \(max 1000 characters\) |
| `sendMessageDenyNotification` | string | Whether to send rejection notifications |
| `defaultMessageDenyNotificationText` | string | Default rejection message text |
| `membersCanPostAsTheGroup` | string | Whether members can post as the group |
| `includeInGlobalAddressList` | string | Whether included in Global Address List |
| `whoCanLeaveGroup` | string | Who can leave the group |
| `whoCanContactOwner` | string | Who can contact the group owner |
| `favoriteRepliesOnTop` | string | Whether favorite replies appear at top |
| `whoCanApproveMembers` | string | Who can approve new members |
| `whoCanBanUsers` | string | Who can ban users |
| `whoCanModerateMembers` | string | Who can manage members |
| `whoCanModerateContent` | string | Who can moderate content |
| `whoCanAssistContent` | string | Who can assist with content metadata |
| `enableCollaborativeInbox` | string | Whether collaborative inbox is enabled |
| `whoCanDiscoverGroup` | string | Who can discover the group |
| `defaultSender` | string | Default sender identity \(DEFAULT_SELF or GROUP\) |
### `google_groups_update_settings`
Update the settings for a Google Group including access permissions, moderation, and posting options
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupEmail` | string | Yes | The email address of the group |
| `name` | string | No | The group name \(max 75 characters\) |
| `description` | string | No | The group description \(max 4096 characters\) |
| `whoCanJoin` | string | No | Who can join: ANYONE_CAN_JOIN, ALL_IN_DOMAIN_CAN_JOIN, INVITED_CAN_JOIN, CAN_REQUEST_TO_JOIN |
| `whoCanViewMembership` | string | No | Who can view membership: ALL_IN_DOMAIN_CAN_VIEW, ALL_MEMBERS_CAN_VIEW, ALL_MANAGERS_CAN_VIEW |
| `whoCanViewGroup` | string | No | Who can view group messages: ANYONE_CAN_VIEW, ALL_IN_DOMAIN_CAN_VIEW, ALL_MEMBERS_CAN_VIEW, ALL_MANAGERS_CAN_VIEW |
| `whoCanPostMessage` | string | No | Who can post: NONE_CAN_POST, ALL_MANAGERS_CAN_POST, ALL_MEMBERS_CAN_POST, ALL_OWNERS_CAN_POST, ALL_IN_DOMAIN_CAN_POST, ANYONE_CAN_POST |
| `allowExternalMembers` | string | No | Whether external users can be members: true or false |
| `allowWebPosting` | string | No | Whether web posting is allowed: true or false |
| `primaryLanguage` | string | No | The group's primary language \(e.g., en\) |
| `isArchived` | string | No | Whether messages are archived: true or false |
| `archiveOnly` | string | No | Whether the group is archive-only \(inactive\): true or false |
| `messageModerationLevel` | string | No | Message moderation: MODERATE_ALL_MESSAGES, MODERATE_NON_MEMBERS, MODERATE_NEW_MEMBERS, MODERATE_NONE |
| `spamModerationLevel` | string | No | Spam handling: ALLOW, MODERATE, SILENTLY_MODERATE, REJECT |
| `replyTo` | string | No | Default reply: REPLY_TO_CUSTOM, REPLY_TO_SENDER, REPLY_TO_LIST, REPLY_TO_OWNER, REPLY_TO_IGNORE, REPLY_TO_MANAGERS |
| `customReplyTo` | string | No | Custom email for replies \(when replyTo is REPLY_TO_CUSTOM\) |
| `includeCustomFooter` | string | No | Whether to include custom footer: true or false |
| `customFooterText` | string | No | Custom footer text \(max 1000 characters\) |
| `sendMessageDenyNotification` | string | No | Whether to send rejection notifications: true or false |
| `defaultMessageDenyNotificationText` | string | No | Default rejection message text |
| `membersCanPostAsTheGroup` | string | No | Whether members can post as the group: true or false |
| `includeInGlobalAddressList` | string | No | Whether included in Global Address List: true or false |
| `whoCanLeaveGroup` | string | No | Who can leave: ALL_MANAGERS_CAN_LEAVE, ALL_MEMBERS_CAN_LEAVE, NONE_CAN_LEAVE |
| `whoCanContactOwner` | string | No | Who can contact owner: ALL_IN_DOMAIN_CAN_CONTACT, ALL_MANAGERS_CAN_CONTACT, ALL_MEMBERS_CAN_CONTACT, ANYONE_CAN_CONTACT |
| `favoriteRepliesOnTop` | string | No | Whether favorite replies appear at top: true or false |
| `whoCanApproveMembers` | string | No | Who can approve members: ALL_OWNERS_CAN_APPROVE, ALL_MANAGERS_CAN_APPROVE, ALL_MEMBERS_CAN_APPROVE, NONE_CAN_APPROVE |
| `whoCanBanUsers` | string | No | Who can ban users: OWNERS_ONLY, OWNERS_AND_MANAGERS, NONE |
| `whoCanModerateMembers` | string | No | Who can manage members: OWNERS_ONLY, OWNERS_AND_MANAGERS, ALL_MEMBERS, NONE |
| `whoCanModerateContent` | string | No | Who can moderate content: OWNERS_ONLY, OWNERS_AND_MANAGERS, ALL_MEMBERS, NONE |
| `whoCanAssistContent` | string | No | Who can assist with content metadata: OWNERS_ONLY, OWNERS_AND_MANAGERS, ALL_MEMBERS, NONE |
| `enableCollaborativeInbox` | string | No | Whether collaborative inbox is enabled: true or false |
| `whoCanDiscoverGroup` | string | No | Who can discover: ANYONE_CAN_DISCOVER, ALL_IN_DOMAIN_CAN_DISCOVER, ALL_MEMBERS_CAN_DISCOVER |
| `defaultSender` | string | No | Default sender: DEFAULT_SELF or GROUP |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `email` | string | The group |
| `name` | string | The group name |
| `description` | string | The group description |
| `whoCanJoin` | string | Who can join the group |
| `whoCanViewMembership` | string | Who can view group membership |
| `whoCanViewGroup` | string | Who can view group messages |
| `whoCanPostMessage` | string | Who can post messages to the group |
| `allowExternalMembers` | string | Whether external users can be members |
| `allowWebPosting` | string | Whether web posting is allowed |
| `primaryLanguage` | string | The group |
| `isArchived` | string | Whether messages are archived |
| `archiveOnly` | string | Whether the group is archive-only |
| `messageModerationLevel` | string | Message moderation level |
| `spamModerationLevel` | string | Spam handling level |
| `replyTo` | string | Default reply destination |
| `customReplyTo` | string | Custom email for replies |
| `includeCustomFooter` | string | Whether to include custom footer |
| `customFooterText` | string | Custom footer text |
| `sendMessageDenyNotification` | string | Whether to send rejection notifications |
| `defaultMessageDenyNotificationText` | string | Default rejection message text |
| `membersCanPostAsTheGroup` | string | Whether members can post as the group |
| `includeInGlobalAddressList` | string | Whether included in Global Address List |
| `whoCanLeaveGroup` | string | Who can leave the group |
| `whoCanContactOwner` | string | Who can contact the group owner |
| `favoriteRepliesOnTop` | string | Whether favorite replies appear at top |
| `whoCanApproveMembers` | string | Who can approve new members |
| `whoCanBanUsers` | string | Who can ban users |
| `whoCanModerateMembers` | string | Who can manage members |
| `whoCanModerateContent` | string | Who can moderate content |
| `whoCanAssistContent` | string | Who can assist with content metadata |
| `enableCollaborativeInbox` | string | Whether collaborative inbox is enabled |
| `whoCanDiscoverGroup` | string | Who can discover the group |
| `defaultSender` | string | Default sender identity |

View File

@@ -28,7 +28,7 @@ In Sim, the Google Sheets integration empowers your agents to automate reading f
## Usage Instructions
Integrate Google Sheets into the workflow with explicit sheet selection. Can read, write, append, update, clear data, create spreadsheets, get spreadsheet info, and copy sheets.
Integrate Google Sheets into the workflow with explicit sheet selection. Can read, write, append, and update data in specific sheets.
@@ -42,8 +42,9 @@ Read data from a specific sheet in a Google Sheets spreadsheet
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet \(found in the URL: docs.google.com/spreadsheets/d/\{SPREADSHEET_ID\}/edit\). |
| `range` | string | No | The A1 notation range to read \(e.g. "Sheet1!A1:D10", "A1:B5"\). Defaults to first sheet A1:Z1000 if not specified. |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
| `sheetName` | string | Yes | The name of the sheet/tab to read from |
| `cellRange` | string | No | The cell range to read \(e.g. "A1:D10"\). Defaults to "A1:Z1000" if not specified. |
#### Output
@@ -65,7 +66,8 @@ Write data to a specific sheet in a Google Sheets spreadsheet
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
| `range` | string | No | The A1 notation range to write to \(e.g. "Sheet1!A1:D10", "A1:B5"\) |
| `sheetName` | string | Yes | The name of the sheet/tab to write to |
| `cellRange` | string | No | The cell range to write to \(e.g. "A1:D10", "A1"\). Defaults to "A1" if not specified. |
| `values` | array | Yes | The data to write as a 2D array \(e.g. \[\["Name", "Age"\], \["Alice", 30\], \["Bob", 25\]\]\) or array of objects. |
| `valueInputOption` | string | No | The format of the data to write |
| `includeValuesInResponse` | boolean | No | Whether to include the written values in the response |
@@ -91,7 +93,8 @@ Update data in a specific sheet in a Google Sheets spreadsheet
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to update |
| `range` | string | No | The A1 notation range to update \(e.g. "Sheet1!A1:D10", "A1:B5"\) |
| `sheetName` | string | Yes | The name of the sheet/tab to update |
| `cellRange` | string | No | The cell range to update \(e.g. "A1:D10", "A1"\). Defaults to "A1" if not specified. |
| `values` | array | Yes | The data to update as a 2D array \(e.g. \[\["Name", "Age"\], \["Alice", 30\]\]\) or array of objects. |
| `valueInputOption` | string | No | The format of the data to update |
| `includeValuesInResponse` | boolean | No | Whether to include the updated values in the response |
@@ -117,7 +120,7 @@ Append data to the end of a specific sheet in a Google Sheets spreadsheet
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to append to |
| `range` | string | No | The A1 notation range to append after \(e.g. "Sheet1", "Sheet1!A:D"\) |
| `sheetName` | string | Yes | The name of the sheet/tab to append to |
| `values` | array | Yes | The data to append as a 2D array \(e.g. \[\["Alice", 30\], \["Bob", 25\]\]\) or array of objects. |
| `valueInputOption` | string | No | The format of the data to append |
| `insertDataOption` | string | No | How to insert the data \(OVERWRITE or INSERT_ROWS\) |
@@ -136,180 +139,4 @@ Append data to the end of a specific sheet in a Google Sheets spreadsheet
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
### `google_sheets_clear`
Clear values from a specific range in a Google Sheets spreadsheet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
| `sheetName` | string | Yes | The name of the sheet/tab to clear |
| `cellRange` | string | No | The cell range to clear \(e.g. "A1:D10"\). Clears entire sheet if not specified. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `clearedRange` | string | The range that was cleared |
| `sheetName` | string | Name of the sheet that was cleared |
| `metadata` | json | Spreadsheet metadata including ID and URL |
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
### `google_sheets_get_spreadsheet`
Get metadata about a Google Sheets spreadsheet including title and sheet list
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
| `includeGridData` | boolean | No | Whether to include grid data \(cell values\). Defaults to false. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `spreadsheetId` | string | The spreadsheet ID |
| `title` | string | The title of the spreadsheet |
| `locale` | string | The locale of the spreadsheet |
| `timeZone` | string | The time zone of the spreadsheet |
| `spreadsheetUrl` | string | URL to the spreadsheet |
| `sheets` | array | List of sheets in the spreadsheet |
| ↳ `sheetId` | number | The sheet ID |
| ↳ `title` | string | The sheet title/name |
| ↳ `index` | number | The sheet index \(position\) |
| ↳ `rowCount` | number | Number of rows in the sheet |
| ↳ `columnCount` | number | Number of columns in the sheet |
| ↳ `hidden` | boolean | Whether the sheet is hidden |
### `google_sheets_create_spreadsheet`
Create a new Google Sheets spreadsheet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `title` | string | Yes | The title of the new spreadsheet |
| `sheetTitles` | json | No | Array of sheet names to create \(e.g., \["Sheet1", "Data", "Summary"\]\). Defaults to a single "Sheet1". |
| `locale` | string | No | The locale of the spreadsheet \(e.g., "en_US"\) |
| `timeZone` | string | No | The time zone of the spreadsheet \(e.g., "America/New_York"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `spreadsheetId` | string | The ID of the created spreadsheet |
| `title` | string | The title of the created spreadsheet |
| `spreadsheetUrl` | string | URL to the created spreadsheet |
| `sheets` | array | List of sheets created in the spreadsheet |
| ↳ `sheetId` | number | The sheet ID |
| ↳ `title` | string | The sheet title/name |
| ↳ `index` | number | The sheet index \(position\) |
### `google_sheets_batch_get`
Read multiple ranges from a Google Sheets spreadsheet in a single request
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
| `ranges` | json | Yes | Array of ranges to read \(e.g., \["Sheet1!A1:D10", "Sheet2!A1:B5"\]\). Each range should include sheet name. |
| `majorDimension` | string | No | The major dimension of values: "ROWS" \(default\) or "COLUMNS" |
| `valueRenderOption` | string | No | How values should be rendered: "FORMATTED_VALUE" \(default\), "UNFORMATTED_VALUE", or "FORMULA" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `spreadsheetId` | string | The spreadsheet ID |
| `valueRanges` | array | Array of value ranges read from the spreadsheet |
| ↳ `range` | string | The range that was read |
| ↳ `majorDimension` | string | Major dimension \(ROWS or COLUMNS\) |
| ↳ `values` | array | The cell values as a 2D array |
| `metadata` | json | Spreadsheet metadata including ID and URL |
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
### `google_sheets_batch_update`
Update multiple ranges in a Google Sheets spreadsheet in a single request
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
| `data` | json | Yes | Array of value ranges to update. Each item should have "range" \(e.g., "Sheet1!A1:D10"\) and "values" \(2D array\). |
| `valueInputOption` | string | No | How input data should be interpreted: "RAW" or "USER_ENTERED" \(default\). USER_ENTERED parses formulas. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `spreadsheetId` | string | The spreadsheet ID |
| `totalUpdatedRows` | number | Total number of rows updated |
| `totalUpdatedColumns` | number | Total number of columns updated |
| `totalUpdatedCells` | number | Total number of cells updated |
| `totalUpdatedSheets` | number | Total number of sheets updated |
| `responses` | array | Array of update responses for each range |
| ↳ `spreadsheetId` | string | The spreadsheet ID |
| ↳ `updatedRange` | string | The range that was updated |
| ↳ `updatedRows` | number | Number of rows updated in this range |
| ↳ `updatedColumns` | number | Number of columns updated in this range |
| ↳ `updatedCells` | number | Number of cells updated in this range |
| `metadata` | json | Spreadsheet metadata including ID and URL |
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
### `google_sheets_batch_clear`
Clear multiple ranges in a Google Sheets spreadsheet in a single request
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
| `ranges` | json | Yes | Array of ranges to clear \(e.g., \["Sheet1!A1:D10", "Sheet2!A1:B5"\]\). Each range should include sheet name. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `spreadsheetId` | string | The spreadsheet ID |
| `clearedRanges` | array | Array of ranges that were cleared |
| `metadata` | json | Spreadsheet metadata including ID and URL |
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
### `google_sheets_copy_sheet`
Copy a sheet from one spreadsheet to another
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `sourceSpreadsheetId` | string | Yes | The ID of the source spreadsheet |
| `sheetId` | number | Yes | The ID of the sheet to copy \(numeric ID, not the sheet name\). Use Get Spreadsheet to find sheet IDs. |
| `destinationSpreadsheetId` | string | Yes | The ID of the destination spreadsheet where the sheet will be copied |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `sheetId` | number | The ID of the newly created sheet in the destination |
| `title` | string | The title of the copied sheet |
| `index` | number | The index \(position\) of the copied sheet |
| `sheetType` | string | The type of the sheet \(GRID, CHART, etc.\) |
| `destinationSpreadsheetId` | string | The ID of the destination spreadsheet |
| `destinationSpreadsheetUrl` | string | URL to the destination spreadsheet |

View File

@@ -30,7 +30,7 @@ In Sim, the Google Slides integration enables your agents to interact directly w
## Usage Instructions
Integrate Google Slides into the workflow. Can read, write, create presentations, replace text, add slides, add images, get thumbnails, get page details, delete objects, duplicate objects, reorder slides, create tables, create shapes, and insert text.
Integrate Google Slides into the workflow. Can read, write, create presentations, replace text, add slides, add images, and get thumbnails.
@@ -177,177 +177,4 @@ Generate a thumbnail image of a specific slide in a Google Slides presentation
| `height` | number | Height of the thumbnail in pixels |
| `metadata` | json | Operation metadata including presentation ID and page object ID |
### `google_slides_get_page`
Get detailed information about a specific slide/page in a Google Slides presentation
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `presentationId` | string | Yes | The ID of the presentation |
| `pageObjectId` | string | Yes | The object ID of the slide/page to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `objectId` | string | The object ID of the page |
| `pageType` | string | The type of page \(SLIDE, MASTER, LAYOUT, NOTES, NOTES_MASTER\) |
| `pageElements` | array | Array of page elements \(shapes, images, tables, etc.\) on this page |
| `slideProperties` | object | Properties specific to slides \(layout, master, notes\) |
| ↳ `layoutObjectId` | string | Object ID of the layout this slide is based on |
| ↳ `masterObjectId` | string | Object ID of the master this slide is based on |
| ↳ `notesPage` | json | The notes page associated with the slide |
| `metadata` | object | Operation metadata including presentation ID and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `url` | string | URL to the presentation |
### `google_slides_delete_object`
Delete a page element (shape, image, table, etc.) or an entire slide from a Google Slides presentation
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `presentationId` | string | Yes | The ID of the presentation |
| `objectId` | string | Yes | The object ID of the element or slide to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the object was successfully deleted |
| `objectId` | string | The object ID that was deleted |
| `metadata` | object | Operation metadata including presentation ID and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `url` | string | URL to the presentation |
### `google_slides_duplicate_object`
Duplicate an object (slide, shape, image, table, etc.) in a Google Slides presentation
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `presentationId` | string | Yes | The ID of the presentation |
| `objectId` | string | Yes | The object ID of the element or slide to duplicate |
| `objectIds` | string | No | Optional JSON object mapping source object IDs \(within the slide being duplicated\) to new object IDs for the duplicates. Format: \{"sourceId1":"newId1","sourceId2":"newId2"\} |
| `Format` | string | No | No description |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `duplicatedObjectId` | string | The object ID of the newly created duplicate |
| `metadata` | object | Operation metadata including presentation ID and source object ID |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `sourceObjectId` | string | The original object ID that was duplicated |
| ↳ `url` | string | URL to the presentation |
### `google_slides_update_slides_position`
Move one or more slides to a new position in a Google Slides presentation
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `presentationId` | string | Yes | The ID of the presentation |
| `slideObjectIds` | string | Yes | Comma-separated list of slide object IDs to move. The slides will maintain their relative order. |
| `insertionIndex` | number | Yes | The zero-based index where the slides should be moved. All slides with indices greater than or equal to this will be shifted right. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `moved` | boolean | Whether the slides were successfully moved |
| `slideObjectIds` | array | The slide object IDs that were moved |
| `insertionIndex` | number | The index where the slides were moved to |
| `metadata` | object | Operation metadata including presentation ID and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `url` | string | URL to the presentation |
### `google_slides_create_table`
Create a new table on a slide in a Google Slides presentation
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `presentationId` | string | Yes | The ID of the presentation |
| `pageObjectId` | string | Yes | The object ID of the slide/page to add the table to |
| `rows` | number | Yes | Number of rows in the table \(minimum 1\) |
| `columns` | number | Yes | Number of columns in the table \(minimum 1\) |
| `width` | number | No | Width of the table in points \(default: 400\) |
| `height` | number | No | Height of the table in points \(default: 200\) |
| `positionX` | number | No | X position from the left edge in points \(default: 100\) |
| `positionY` | number | No | Y position from the top edge in points \(default: 100\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableId` | string | The object ID of the newly created table |
| `rows` | number | Number of rows in the table |
| `columns` | number | Number of columns in the table |
| `metadata` | object | Operation metadata including presentation ID and page object ID |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `pageObjectId` | string | The page object ID where the table was created |
| ↳ `url` | string | URL to the presentation |
### `google_slides_create_shape`
Create a shape (rectangle, ellipse, text box, arrow, etc.) on a slide in a Google Slides presentation
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `presentationId` | string | Yes | The ID of the presentation |
| `pageObjectId` | string | Yes | The object ID of the slide/page to add the shape to |
| `shapeType` | string | Yes | The type of shape to create. Common types: TEXT_BOX, RECTANGLE, ROUND_RECTANGLE, ELLIPSE, TRIANGLE, DIAMOND, STAR_5, ARROW_EAST, HEART, CLOUD |
| `width` | number | No | Width of the shape in points \(default: 200\) |
| `height` | number | No | Height of the shape in points \(default: 100\) |
| `positionX` | number | No | X position from the left edge in points \(default: 100\) |
| `positionY` | number | No | Y position from the top edge in points \(default: 100\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `shapeId` | string | The object ID of the newly created shape |
| `shapeType` | string | The type of shape that was created |
| `metadata` | object | Operation metadata including presentation ID and page object ID |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `pageObjectId` | string | The page object ID where the shape was created |
| ↳ `url` | string | URL to the presentation |
### `google_slides_insert_text`
Insert text into a shape or table cell in a Google Slides presentation. Use this to add text to text boxes, shapes, or table cells.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `presentationId` | string | Yes | The ID of the presentation |
| `objectId` | string | Yes | The object ID of the shape or table cell to insert text into. For table cells, use the cell object ID. |
| `text` | string | Yes | The text to insert |
| `insertionIndex` | number | No | The zero-based index at which to insert the text. If not specified, text is inserted at the beginning \(index 0\). |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `inserted` | boolean | Whether the text was successfully inserted |
| `objectId` | string | The object ID where text was inserted |
| `text` | string | The text that was inserted |
| `metadata` | object | Operation metadata including presentation ID and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `url` | string | URL to the presentation |

View File

@@ -51,7 +51,6 @@ Search for similar content in a knowledge base using vector similarity
| `properties` | string | No | No description |
| `tagName` | string | No | No description |
| `tagValue` | string | No | No description |
| `tagFilters` | string | No | No description |
#### Output
@@ -109,8 +108,19 @@ Create a new document in a knowledge base
| `knowledgeBaseId` | string | Yes | ID of the knowledge base containing the document |
| `name` | string | Yes | Name of the document |
| `content` | string | Yes | Content of the document |
| `documentTags` | object | No | Document tags |
| `documentTags` | string | No | No description |
| `tag1` | string | No | Tag 1 value for the document |
| `tag2` | string | No | Tag 2 value for the document |
| `tag3` | string | No | Tag 3 value for the document |
| `tag4` | string | No | Tag 4 value for the document |
| `tag5` | string | No | Tag 5 value for the document |
| `tag6` | string | No | Tag 6 value for the document |
| `tag7` | string | No | Tag 7 value for the document |
| `documentTagsData` | array | No | Structured tag data with names, types, and values |
| `items` | object | No | No description |
| `properties` | string | No | No description |
| `tagName` | string | No | No description |
| `tagValue` | string | No | No description |
| `tagType` | string | No | No description |
#### Output

View File

@@ -45,7 +45,8 @@ Read data from a specific sheet in a Microsoft Excel spreadsheet
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to read from |
| `range` | string | No | The range of cells to read from. Accepts "SheetName!A1:B2" for explicit ranges or just "SheetName" to read the used range of that sheet. If omitted, reads the used range of the first sheet. |
| `sheetName` | string | Yes | The name of the sheet/tab to read from |
| `cellRange` | string | No | The cell range to read \(e.g., "A1:D10"\). If not specified, reads the entire used range. |
#### Output
@@ -67,8 +68,9 @@ Write data to a specific sheet in a Microsoft Excel spreadsheet
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to write to |
| `range` | string | No | The range of cells to write to |
| `values` | array | Yes | The data to write to the spreadsheet |
| `sheetName` | string | Yes | The name of the sheet/tab to write to |
| `cellRange` | string | No | The cell range to write to \(e.g., "A1:D10", "A1"\). Defaults to "A1" if not specified. |
| `values` | array | Yes | The data to write as a 2D array \(e.g. \[\["Name", "Age"\], \["Alice", 30\], \["Bob", 25\]\]\) or array of objects. |
| `valueInputOption` | string | No | The format of the data to write |
| `includeValuesInResponse` | boolean | No | Whether to include the written values in the response |

View File

@@ -84,10 +84,9 @@ Send messages to Slack channels or direct messages. Supports Slack mrkdwn format
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | No | Authentication method: oauth or bot_token |
| `destinationType` | string | No | Destination type: channel or dm |
| `botToken` | string | No | Bot token for Custom Bot |
| `channel` | string | No | Target Slack channel \(e.g., #general\) |
| `dmUserId` | string | No | Target Slack user for direct messages |
| `userId` | string | No | Target Slack user ID for direct messages \(e.g., U1234567890\) |
| `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) |
| `thread_ts` | string | No | Thread timestamp to reply to \(creates thread reply\) |
| `files` | file[] | No | Files to attach to the message |
@@ -133,10 +132,9 @@ Read the latest messages from Slack channels. Retrieve conversation history with
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | No | Authentication method: oauth or bot_token |
| `destinationType` | string | No | Destination type: channel or dm |
| `botToken` | string | No | Bot token for Custom Bot |
| `channel` | string | No | Slack channel to read messages from \(e.g., #general\) |
| `dmUserId` | string | No | Target Slack user for DM conversation |
| `userId` | string | No | User ID for DM conversation \(e.g., U1234567890\) |
| `limit` | number | No | Number of messages to retrieve \(default: 10, max: 15\) |
| `oldest` | string | No | Start of time range \(timestamp\) |
| `latest` | string | No | End of time range \(timestamp\) |

View File

@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getSession } from '@/lib/auth'
import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants'
import { createTagDefinition, getTagDefinitions } from '@/lib/knowledge/tags/service'
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
@@ -19,32 +19,19 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
try {
logger.info(`[${requestId}] Getting tag definitions for knowledge base ${knowledgeBaseId}`)
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Only allow session and internal JWT auth (not API key)
if (auth.authType === 'api_key') {
return NextResponse.json(
{ error: 'API key auth not supported for this endpoint' },
{ status: 401 }
)
}
// For session auth, verify KB access. Internal JWT is trusted.
if (auth.authType === 'session' && auth.userId) {
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const tagDefinitions = await getTagDefinitions(knowledgeBaseId)
logger.info(
`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions (${auth.authType})`
)
logger.info(`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions`)
return NextResponse.json({
success: true,
@@ -64,25 +51,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
try {
logger.info(`[${requestId}] Creating tag definition for knowledge base ${knowledgeBaseId}`)
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Only allow session and internal JWT auth (not API key)
if (auth.authType === 'api_key') {
return NextResponse.json(
{ error: 'API key auth not supported for this endpoint' },
{ status: 401 }
)
}
// For session auth, verify KB access. Internal JWT is trusted.
if (auth.authType === 'session' && auth.userId) {
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await req.json()

View File

@@ -1,4 +1,4 @@
import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react'
import React, { type HTMLAttributes, type ReactNode } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Tooltip } from '@/components/emcn'
@@ -23,16 +23,24 @@ export function LinkWithPreview({ href, children }: { href: string; children: Re
)
}
const REMARK_PLUGINS = [remarkGfm]
export default function MarkdownRenderer({
content,
customLinkComponent,
}: {
content: string
customLinkComponent?: typeof LinkWithPreview
}) {
const LinkComponent = customLinkComponent || LinkWithPreview
function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
return {
const customComponents = {
// Paragraph
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className='mb-1 font-sans text-base text-gray-800 leading-relaxed last:mb-0 dark:text-gray-200'>
{children}
</p>
),
// Headings
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className='mt-10 mb-5 font-sans font-semibold text-2xl text-gray-900 dark:text-gray-100'>
{children}
@@ -54,6 +62,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
</h4>
),
// Lists
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
<ul
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-gray-800 dark:text-gray-200'
@@ -80,6 +89,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
</li>
),
// Code blocks
pre: ({ children }: HTMLAttributes<HTMLPreElement>) => {
let codeProps: HTMLAttributes<HTMLElement> = {}
let codeContent: ReactNode = children
@@ -110,6 +120,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
)
},
// Inline code
code: ({
inline,
className,
@@ -133,20 +144,24 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
)
},
// Blockquotes
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote className='my-4 border-gray-300 border-l-4 py-1 pl-4 font-sans text-gray-700 italic dark:border-gray-600 dark:text-gray-300'>
{children}
</blockquote>
),
// Horizontal rule
hr: () => <hr className='my-8 border-gray-500/[.07] border-t dark:border-gray-400/[.07]' />,
// Links
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<LinkComponent href={href || '#'} {...props}>
{children}
</LinkComponent>
),
// Tables
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
<div className='my-4 w-full overflow-x-auto'>
<table className='min-w-full table-auto border border-gray-300 font-sans text-sm dark:border-gray-700'>
@@ -178,6 +193,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
</td>
),
// Images
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img
src={src}
@@ -187,33 +203,15 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
/>
),
}
}
const DEFAULT_COMPONENTS = createCustomComponents(LinkWithPreview)
const MarkdownRenderer = memo(function MarkdownRenderer({
content,
customLinkComponent,
}: {
content: string
customLinkComponent?: typeof LinkWithPreview
}) {
const components = useMemo(() => {
if (!customLinkComponent) {
return DEFAULT_COMPONENTS
}
return createCustomComponents(customLinkComponent)
}, [customLinkComponent])
// Pre-process content to fix common issues
const processedContent = content.trim()
return (
<div className='space-y-4 break-words font-sans text-[#0D0D0D] text-base leading-relaxed dark:text-gray-100'>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={customComponents}>
{processedContent}
</ReactMarkdown>
</div>
)
})
export default MarkdownRenderer
}

View File

@@ -7,7 +7,7 @@ import { generateBrandedMetadata, generateStructuredData } from '@/lib/branding/
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
import '@/app/_styles/globals.css'
import { OneDollarStats } from '@/components/analytics/onedollarstats'
import { isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/feature-flags'
import { isReactGrabEnabled } from '@/lib/core/config/feature-flags'
import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler'
import { QueryProvider } from '@/app/_shell/providers/query-provider'
import { SessionProvider } from '@/app/_shell/providers/session-provider'
@@ -35,13 +35,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang='en' suppressHydrationWarning>
<head>
{isReactScanEnabled && (
<Script
src='https://unpkg.com/react-scan/dist/auto.global.js'
crossOrigin='anonymous'
strategy='beforeInteractive'
/>
)}
{isReactGrabEnabled && (
<Script
src='https://unpkg.com/react-grab/dist/index.global.js'

View File

@@ -1,13 +1,10 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { SupportFooter } from '@/app/(auth)/components/support-footer'
import { InviteLayout } from '@/app/invite/components'
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { useBrandConfig } from '@/lib/branding/branding'
interface UnsubscribeData {
success: boolean
@@ -30,6 +27,7 @@ function UnsubscribeContent() {
const [error, setError] = useState<string | null>(null)
const [processing, setProcessing] = useState(false)
const [unsubscribed, setUnsubscribed] = useState(false)
const brand = useBrandConfig()
const email = searchParams.get('email')
const token = searchParams.get('token')
@@ -111,7 +109,7 @@ function UnsubscribeContent() {
} else {
setError(result.error || 'Failed to unsubscribe')
}
} catch {
} catch (error) {
setError('Failed to process unsubscribe request')
} finally {
setProcessing(false)
@@ -120,171 +118,272 @@ function UnsubscribeContent() {
if (loading) {
return (
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Loading
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Validating your unsubscribe link...
</p>
</div>
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</div>
<SupportFooter position='absolute' />
</InviteLayout>
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardContent className='flex items-center justify-center p-8'>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</CardContent>
</Card>
</div>
)
}
if (error) {
return (
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Invalid Unsubscribe Link
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
{error}
</p>
</div>
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' />
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle>
<CardDescription className='text-muted-foreground'>
This unsubscribe link is invalid or has expired
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border bg-red-50 p-4'>
<p className='text-red-800 text-sm'>
<strong>Error:</strong> {error}
</p>
</div>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<BrandedButton onClick={() => window.history.back()}>Go Back</BrandedButton>
</div>
<div className='space-y-3'>
<p className='text-muted-foreground text-sm'>This could happen if:</p>
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
<li>The link is missing required parameters</li>
<li>The link has expired or been used already</li>
<li>The link was copied incorrectly</li>
</ul>
</div>
<SupportFooter position='absolute' />
</InviteLayout>
<div className='mt-6 flex flex-col gap-3'>
<Button
onClick={() =>
window.open(
`mailto:${brand.supportEmail}?subject=Unsubscribe%20Help&body=Hi%2C%20I%20need%20help%20unsubscribing%20from%20emails.%20My%20unsubscribe%20link%20is%20not%20working.`,
'_blank'
)
}
className='w-full bg-[var(--brand-primary-hex)] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
>
Contact Support
</Button>
<Button onClick={() => window.history.back()} variant='outline' className='w-full'>
Go Back
</Button>
</div>
<div className='mt-4 text-center'>
<p className='text-muted-foreground text-xs'>
Need immediate help? Email us at{' '}
<a
href={`mailto:${brand.supportEmail}`}
className='text-muted-foreground hover:underline'
>
{brand.supportEmail}
</a>
</p>
</div>
</CardContent>
</Card>
</div>
)
}
if (data?.isTransactional) {
return (
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Important Account Emails
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Transactional emails like password resets, account confirmations, and security alerts
cannot be unsubscribed from as they contain essential information for your account.
</p>
</div>
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' />
<CardTitle className='text-foreground'>Important Account Emails</CardTitle>
<CardDescription className='text-muted-foreground'>
This email contains important information about your account
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border bg-blue-50 p-4'>
<p className='text-blue-800 text-sm'>
<strong>Transactional emails</strong> like password resets, account confirmations,
and security alerts cannot be unsubscribed from as they contain essential
information for your account security and functionality.
</p>
</div>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
</div>
<div className='space-y-3'>
<p className='text-foreground text-sm'>
If you no longer wish to receive these emails, you can:
</p>
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
<li>Close your account entirely</li>
<li>Contact our support team for assistance</li>
</ul>
</div>
<SupportFooter position='absolute' />
</InviteLayout>
<div className='mt-6 flex flex-col gap-3'>
<Button
onClick={() =>
window.open(
`mailto:${brand.supportEmail}?subject=Account%20Help&body=Hi%2C%20I%20need%20help%20with%20my%20account%20emails.`,
'_blank'
)
}
className='w-full bg-blue-600 text-white hover:bg-blue-700'
>
Contact Support
</Button>
<Button onClick={() => window.close()} variant='outline' className='w-full'>
Close
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
if (unsubscribed) {
return (
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Successfully Unsubscribed
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
You have been unsubscribed from our emails. You will stop receiving emails within 48
hours.
</p>
</div>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
</div>
<SupportFooter position='absolute' />
</InviteLayout>
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' />
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle>
<CardDescription className='text-muted-foreground'>
You have been unsubscribed from our emails. You will stop receiving emails within 48
hours.
</CardDescription>
</CardHeader>
<CardContent className='text-center'>
<p className='text-muted-foreground text-sm'>
If you change your mind, you can always update your email preferences in your account
settings or contact us at{' '}
<a
href={`mailto:${brand.supportEmail}`}
className='text-muted-foreground hover:underline'
>
{brand.supportEmail}
</a>
</p>
</CardContent>
</Card>
</div>
)
}
const isAlreadyUnsubscribedFromAll = data?.currentPreferences.unsubscribeAll
return (
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Email Preferences
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Choose which emails you'd like to stop receiving.
</p>
<p className={`${inter.className} mt-2 font-[380] text-[14px] text-muted-foreground`}>
{data?.email}
</p>
</div>
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' />
<CardTitle className='text-foreground'>We&apos;re sorry to see you go!</CardTitle>
<CardDescription className='text-muted-foreground'>
We understand email preferences are personal. Choose which emails you&apos;d like to
stop receiving from Sim.
</CardDescription>
<div className='mt-2 rounded-lg border bg-muted/50 p-3'>
<p className='text-muted-foreground text-xs'>
Email: <span className='font-medium text-foreground'>{data?.email}</span>
</p>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-3'>
<Button
onClick={() => handleUnsubscribe('all')}
disabled={processing || data?.currentPreferences.unsubscribeAll}
variant='destructive'
className='w-full'
>
{data?.currentPreferences.unsubscribeAll ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{processing
? 'Unsubscribing...'
: data?.currentPreferences.unsubscribeAll
? 'Unsubscribed from All Emails'
: 'Unsubscribe from All Marketing Emails'}
</Button>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<BrandedButton
onClick={() => handleUnsubscribe('all')}
disabled={processing || isAlreadyUnsubscribedFromAll}
loading={processing}
loadingText='Unsubscribing'
>
{isAlreadyUnsubscribedFromAll
? 'Unsubscribed from All Emails'
: 'Unsubscribe from All Marketing Emails'}
</BrandedButton>
<div className='text-center text-muted-foreground text-sm'>
or choose specific types:
</div>
<div className='py-2 text-center'>
<span className={`${inter.className} font-[380] text-[14px] text-muted-foreground`}>
or choose specific types
</span>
</div>
<Button
onClick={() => handleUnsubscribe('marketing')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeMarketing
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeMarketing ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeMarketing
? 'Unsubscribed from Marketing'
: 'Unsubscribe from Marketing Emails'}
</Button>
<BrandedButton
onClick={() => handleUnsubscribe('marketing')}
disabled={
processing ||
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeMarketing
}
>
{data?.currentPreferences.unsubscribeMarketing
? 'Unsubscribed from Marketing'
: 'Unsubscribe from Marketing Emails'}
</BrandedButton>
<Button
onClick={() => handleUnsubscribe('updates')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeUpdates
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeUpdates ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeUpdates
? 'Unsubscribed from Updates'
: 'Unsubscribe from Product Updates'}
</Button>
<BrandedButton
onClick={() => handleUnsubscribe('updates')}
disabled={
processing ||
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeUpdates
}
>
{data?.currentPreferences.unsubscribeUpdates
? 'Unsubscribed from Updates'
: 'Unsubscribe from Product Updates'}
</BrandedButton>
<Button
onClick={() => handleUnsubscribe('notifications')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeNotifications
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeNotifications ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeNotifications
? 'Unsubscribed from Notifications'
: 'Unsubscribe from Notifications'}
</Button>
</div>
<BrandedButton
onClick={() => handleUnsubscribe('notifications')}
disabled={
processing ||
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeNotifications
}
>
{data?.currentPreferences.unsubscribeNotifications
? 'Unsubscribed from Notifications'
: 'Unsubscribe from Notifications'}
</BrandedButton>
</div>
<div className='mt-6 space-y-3'>
<div className='rounded-lg border bg-muted/50 p-3'>
<p className='text-center text-muted-foreground text-xs'>
<strong>Note:</strong> You&apos;ll continue receiving important account emails like
password resets and security alerts.
</p>
</div>
<div className={`${inter.className} mt-6 max-w-[410px] text-center`}>
<p className='font-[380] text-[13px] text-muted-foreground'>
You'll continue receiving important account emails like password resets and security
alerts.
</p>
</div>
<SupportFooter position='absolute' />
</InviteLayout>
<p className='text-center text-muted-foreground text-xs'>
Questions? Contact us at{' '}
<a
href={`mailto:${brand.supportEmail}`}
className='text-muted-foreground hover:underline'
>
{brand.supportEmail}
</a>
</p>
</div>
</CardContent>
</Card>
</div>
)
}
@@ -292,20 +391,13 @@ export default function Unsubscribe() {
return (
<Suspense
fallback={
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Loading
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Validating your unsubscribe link...
</p>
</div>
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</div>
<SupportFooter position='absolute' />
</InviteLayout>
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardContent className='flex items-center justify-center p-8'>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</CardContent>
</Card>
</div>
}
>
<UnsubscribeContent />

View File

@@ -4,13 +4,13 @@ import type React from 'react'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import {
useWorkspacePermissions,
type WorkspacePermissions,
} from '@/hooks/use-workspace-permissions'
import { useNotificationStore } from '@/stores/notifications'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
const logger = createLogger('WorkspacePermissionsProvider')
@@ -64,8 +64,8 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
// Track whether we've already surfaced an offline notification to avoid duplicates
const [hasShownOfflineNotification, setHasShownOfflineNotification] = useState(false)
// Get operation error state directly from the store (avoid full useCollaborativeWorkflow subscription)
const hasOperationError = useOperationQueueStore((state) => state.hasOperationError)
// Get operation error state from collaborative workflow
const { hasOperationError } = useCollaborativeWorkflow()
const addNotification = useNotificationStore((state) => state.addNotification)

View File

@@ -48,17 +48,17 @@ export const ActionBar = memo(
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
} = useCollaborativeWorkflow()
const { activeWorkflowId, setPendingSelection } = useWorkflowRegistry()
const { activeWorkflowId } = useWorkflowRegistry()
const blocks = useWorkflowStore((state) => state.blocks)
const subBlockStore = useSubBlockStore()
const handleDuplicateBlock = useCallback(() => {
const blocks = useWorkflowStore.getState().blocks
const sourceBlock = blocks[blockId]
if (!sourceBlock) return
const newId = crypto.randomUUID()
const newName = getUniqueBlockName(sourceBlock.name, blocks)
const subBlockValues =
useSubBlockStore.getState().workflowValues[activeWorkflowId || '']?.[blockId] || {}
const subBlockValues = subBlockStore.workflowValues[activeWorkflowId || '']?.[blockId] || {}
const { block, subBlockValues: filteredValues } = prepareDuplicateBlockState({
sourceBlock,
@@ -68,10 +68,18 @@ export const ActionBar = memo(
subBlockValues,
})
setPendingSelection([newId])
collaborativeBatchAddBlocks([block], [], {}, {}, { [newId]: filteredValues })
}, [blockId, activeWorkflowId, collaborativeBatchAddBlocks, setPendingSelection])
}, [
blockId,
blocks,
activeWorkflowId,
subBlockStore.workflowValues,
collaborativeBatchAddBlocks,
])
/**
* Optimized single store subscription for all block data
*/
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
useCallback(
(state) => {

View File

@@ -3,11 +3,13 @@ import ReactMarkdown from 'react-markdown'
import type { NodeProps } from 'reactflow'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/core/utils/cn'
import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import {
BLOCK_DIMENSIONS,
useBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { WorkflowBlockProps } from '../workflow-block/types'

View File

@@ -1,22 +0,0 @@
import { PopoverSection } from '@/components/emcn'
/**
* Skeleton loading component for chat history dropdown
* Displays placeholder content while chats are being loaded
*/
export function ChatHistorySkeleton() {
return (
<>
<PopoverSection>
<div className='h-3 w-12 animate-pulse rounded bg-muted/40' />
</PopoverSection>
<div className='flex flex-col gap-0.5'>
{[1, 2, 3].map((i) => (
<div key={i} className='flex h-[25px] items-center px-[6px]'>
<div className='h-3 w-full animate-pulse rounded bg-muted/40' />
</div>
))}
</div>
</>
)
}

View File

@@ -1,56 +0,0 @@
import { Button } from '@/components/emcn'
interface CheckpointDiscardModalProps {
isProcessingDiscard: boolean
onCancel: () => void
onRevert: () => void
onContinue: () => void
}
/**
* Inline confirmation modal for discarding checkpoints during message editing
* Shows options to cancel, revert to checkpoint, or continue without reverting
*/
export function CheckpointDiscardModal({
isProcessingDiscard,
onCancel,
onRevert,
onContinue,
}: CheckpointDiscardModalProps) {
return (
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
Continue from a previous message?
</p>
<div className='flex gap-[8px]'>
<Button
onClick={onCancel}
variant='active'
size='sm'
className='flex-1'
disabled={isProcessingDiscard}
>
Cancel
</Button>
<Button
onClick={onRevert}
variant='destructive'
size='sm'
className='flex-1'
disabled={isProcessingDiscard}
>
{isProcessingDiscard ? 'Reverting...' : 'Revert'}
</Button>
<Button
onClick={onContinue}
variant='tertiary'
size='sm'
className='flex-1'
disabled={isProcessingDiscard}
>
Continue
</Button>
</div>
</div>
)
}

View File

@@ -1,7 +1,5 @@
export * from './checkpoint-discard-modal'
export * from './file-display'
export { CopilotMarkdownRenderer } from './markdown-renderer'
export * from './restore-checkpoint-modal'
export { default as CopilotMarkdownRenderer } from './markdown-renderer'
export * from './smooth-streaming'
export * from './thinking-block'
export * from './usage-limit-actions'

View File

@@ -6,8 +6,6 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Code, Tooltip } from '@/components/emcn'
const REMARK_PLUGINS = [remarkGfm]
/**
* Recursively extracts text content from React elements
* @param element - React node to extract text from
@@ -151,12 +149,14 @@ interface CopilotMarkdownRendererProps {
* Tighter spacing compared to traditional prose for better chat UX
*/
const markdownComponents = {
// Paragraphs - tight spacing, no margin on last
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className='mb-1.5 font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] last:mb-0 dark:font-[470]'>
{children}
</p>
),
// Headings - minimal margins for chat context
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className='mt-2 mb-1 font-season font-semibold text-[var(--text-primary)] text-base first:mt-0'>
{children}
@@ -178,6 +178,7 @@ const markdownComponents = {
</h4>
),
// Lists - compact spacing
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
<ul
className='my-1 space-y-0.5 pl-5 font-base font-season text-[var(--text-primary)] dark:font-[470]'
@@ -203,6 +204,7 @@ const markdownComponents = {
</li>
),
// Code blocks - handled by CodeBlock component
pre: ({ children }: React.HTMLAttributes<HTMLPreElement>) => {
let codeContent: React.ReactNode = children
let language = 'code'
@@ -241,6 +243,7 @@ const markdownComponents = {
return <CodeBlock code={actualCodeText} language={language} />
},
// Inline code
code: ({
className,
children,
@@ -254,6 +257,7 @@ const markdownComponents = {
</code>
),
// Text formatting
strong: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<strong className='font-semibold text-[var(--text-primary)]'>{children}</strong>
),
@@ -267,18 +271,22 @@ const markdownComponents = {
<i className='text-[var(--text-primary)] italic'>{children}</i>
),
// Blockquote - compact
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote className='my-1.5 border-[var(--border-1)] border-l-2 py-0.5 pl-3 font-season text-[var(--text-secondary)] text-sm italic'>
{children}
</blockquote>
),
// Horizontal rule
hr: () => <hr className='my-3 border-[var(--divider)] border-t' />,
// Links
a: ({ href, children }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<LinkWithPreview href={href || '#'}>{children}</LinkWithPreview>
),
// Tables - compact
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
<div className='my-2 max-w-full overflow-x-auto'>
<table className='min-w-full table-auto border border-[var(--border-1)] font-season text-xs'>
@@ -306,6 +314,7 @@ const markdownComponents = {
</td>
),
// Images
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img src={src} alt={alt || 'Image'} className='my-2 h-auto max-w-full rounded-md' {...props} />
),
@@ -321,7 +330,7 @@ const markdownComponents = {
function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) {
return (
<div className='max-w-full break-words font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] dark:font-[470] [&_*]:max-w-full [&_a]:break-all [&_code:not(pre_code)]:break-words [&_li]:break-words [&_p]:break-words'>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={markdownComponents}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{content}
</ReactMarkdown>
</div>

View File

@@ -1 +0,0 @@
export { default as CopilotMarkdownRenderer } from './markdown-renderer'

View File

@@ -1,46 +0,0 @@
import { Button } from '@/components/emcn'
interface RestoreCheckpointModalProps {
isReverting: boolean
onCancel: () => void
onConfirm: () => void
}
/**
* Inline confirmation modal for restoring a checkpoint
* Warns user that the action cannot be undone
*/
export function RestoreCheckpointModal({
isReverting,
onCancel,
onConfirm,
}: RestoreCheckpointModalProps) {
return (
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
Revert to checkpoint? This will restore your workflow to the state saved at this checkpoint.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
<div className='flex gap-[8px]'>
<Button
onClick={onCancel}
variant='active'
size='sm'
className='flex-1'
disabled={isReverting}
>
Cancel
</Button>
<Button
onClick={onConfirm}
variant='destructive'
size='sm'
className='flex-1'
disabled={isReverting}
>
{isReverting ? 'Reverting...' : 'Revert'}
</Button>
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { memo, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/core/utils/cn'
import { CopilotMarkdownRenderer } from '../markdown-renderer'
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
/**
* Character animation delay in milliseconds

View File

@@ -3,20 +3,15 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp } from 'lucide-react'
import { CopilotMarkdownRenderer } from '../markdown-renderer'
import CopilotMarkdownRenderer from './markdown-renderer'
/**
* Removes thinking tags (raw or escaped) from streamed content.
* Also strips special tags (options, plan) that may have been accidentally included.
*/
function stripThinkingTags(text: string): string {
return text
.replace(/<\/?thinking[^>]*>/gi, '')
.replace(/&lt;\/?thinking[^&]*&gt;/gi, '')
.replace(/<options>[\s\S]*?<\/options>/gi, '') // Strip complete options tags
.replace(/<options>[\s\S]*$/gi, '') // Strip incomplete/streaming options tags
.replace(/<plan>[\s\S]*?<\/plan>/gi, '') // Strip complete plan tags
.replace(/<plan>[\s\S]*$/gi, '') // Strip incomplete/streaming plan tags
.trim()
}

View File

@@ -9,22 +9,18 @@ import {
ToolCall,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
import {
CheckpointDiscardModal,
FileAttachmentDisplay,
RestoreCheckpointModal,
SmoothStreamingText,
StreamingIndicator,
ThinkingBlock,
UsageLimitActions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components'
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import {
useCheckpointManagement,
useMessageContentAnalysis,
useMessageEditing,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks'
import { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
import { buildMentionHighlightNodes } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { CopilotMessage as CopilotMessageType } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel'
@@ -183,32 +179,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
return message.content ? parseSpecialTags(message.content) : null
}, [message.content, message.contentBlocks, isUser, isStreaming])
// Detect previously selected option by checking if the next user message matches an option
const selectedOptionKey = useMemo(() => {
if (!parsedTags?.options || isStreaming) return null
// Find the index of this message in the messages array
const currentIndex = messages.findIndex((m) => m.id === message.id)
if (currentIndex === -1 || currentIndex >= messages.length - 1) return null
// Get the next message
const nextMessage = messages[currentIndex + 1]
if (!nextMessage || nextMessage.role !== 'user') return null
const nextContent = nextMessage.content?.trim()
if (!nextContent) return null
// Check if the next user message content matches any option title
for (const [key, option] of Object.entries(parsedTags.options)) {
const optionTitle = typeof option === 'string' ? option : option.title
if (nextContent === optionTitle) {
return key
}
}
return null
}, [parsedTags?.options, messages, message.id, isStreaming])
// Get sendMessage from store for continuation actions
const sendMessage = useCopilotStore((s) => s.sendMessage)
@@ -221,9 +191,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
[sendMessage]
)
// Analyze message content for visibility (used for assistant messages)
const { hasVisibleContent } = useMessageContentAnalysis({ message })
// Memoize content blocks to avoid re-rendering unchanged blocks
// No entrance animations to prevent layout shift
const memoizedContentBlocks = useMemo(() => {
@@ -323,12 +290,40 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
{/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */}
{showCheckpointDiscardModal && (
<CheckpointDiscardModal
isProcessingDiscard={isProcessingDiscard}
onCancel={handleCancelCheckpointDiscard}
onRevert={handleContinueAndRevert}
onContinue={handleContinueWithoutRevert}
/>
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
Continue from a previous message?
</p>
<div className='flex gap-[8px]'>
<Button
onClick={handleCancelCheckpointDiscard}
variant='active'
size='sm'
className='flex-1'
disabled={isProcessingDiscard}
>
Cancel
</Button>
<Button
onClick={handleContinueAndRevert}
variant='destructive'
size='sm'
className='flex-1'
disabled={isProcessingDiscard}
>
{isProcessingDiscard ? 'Reverting...' : 'Revert'}
</Button>
<Button
onClick={handleContinueWithoutRevert}
variant='tertiary'
size='sm'
className='flex-1'
disabled={isProcessingDiscard}
>
Continue
</Button>
</div>
</div>
)}
</div>
) : (
@@ -353,15 +348,46 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
ref={messageContentRef}
className={`relative whitespace-pre-wrap break-words px-[2px] py-1 font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.25rem] ${isSendingMessage && isLastUserMessage && isHoveringMessage ? 'pr-7' : ''} ${!isExpanded && needsExpansion ? 'max-h-[60px] overflow-hidden' : 'overflow-visible'}`}
>
{buildMentionHighlightNodes(
message.content || '',
message.contexts || [],
(token, key) => (
<span key={key} className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'>
{token}
</span>
)
)}
{(() => {
const text = message.content || ''
const contexts: any[] = Array.isArray((message as any).contexts)
? ((message as any).contexts as any[])
: []
// Build tokens with their prefixes (@ for mentions, / for commands)
const tokens = contexts
.filter((c) => c?.kind !== 'current_workflow' && c?.label)
.map((c) => {
const prefix = c?.kind === 'slash_command' ? '/' : '@'
return `${prefix}${c.label}`
})
if (!tokens.length) return text
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g')
const nodes: React.ReactNode[] = []
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = pattern.exec(text)) !== null) {
const i = match.index
const before = text.slice(lastIndex, i)
if (before) nodes.push(before)
const mention = match[0]
nodes.push(
<span
key={`mention-${i}-${lastIndex}`}
className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'
>
{mention}
</span>
)
lastIndex = i + mention.length
}
const tail = text.slice(lastIndex)
if (tail) nodes.push(tail)
return nodes
})()}
</div>
{/* Gradient fade when truncated - applies to entire message box */}
@@ -413,16 +439,50 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
{/* Inline Restore Checkpoint Confirmation */}
{showRestoreConfirmation && (
<RestoreCheckpointModal
isReverting={isReverting}
onCancel={handleCancelRevert}
onConfirm={handleConfirmRevert}
/>
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
Revert to checkpoint? This will restore your workflow to the state saved at this
checkpoint.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
<div className='flex gap-[8px]'>
<Button
onClick={handleCancelRevert}
variant='active'
size='sm'
className='flex-1'
disabled={isReverting}
>
Cancel
</Button>
<Button
onClick={handleConfirmRevert}
variant='destructive'
size='sm'
className='flex-1'
disabled={isReverting}
>
{isReverting ? 'Reverting...' : 'Revert'}
</Button>
</div>
</div>
)}
</div>
)
}
// Check if there's any visible content in the blocks
const hasVisibleContent = useMemo(() => {
if (!message.contentBlocks || message.contentBlocks.length === 0) return false
return message.contentBlocks.some((block) => {
if (block.type === 'text') {
const parsed = parseSpecialTags(block.content)
return parsed.cleanContent.trim().length > 0
}
return block.type === 'thinking' || block.type === 'tool_call'
})
}, [message.contentBlocks])
if (isAssistant) {
return (
<div
@@ -474,7 +534,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
isLastMessage && !isStreaming && parsedTags.optionsComplete === true
}
streaming={isStreaming || !parsedTags.optionsComplete}
selectedOptionKey={selectedOptionKey}
/>
)}
</div>

View File

@@ -1,3 +1,4 @@
export { useCheckpointManagement } from './use-checkpoint-management'
export { useMessageContentAnalysis } from './use-message-content-analysis'
export { useMessageEditing } from './use-message-editing'
export { useMessageFeedback } from './use-message-feedback'
export { useSuccessTimers } from './use-success-timers'

View File

@@ -1,31 +0,0 @@
import { useMemo } from 'react'
import { parseSpecialTags } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
import type { CopilotMessage } from '@/stores/panel'
interface UseMessageContentAnalysisProps {
message: CopilotMessage
}
/**
* Hook to analyze message content blocks for visibility and content
* Determines if there's any visible content to display
*
* @param props - Configuration containing the message to analyze
* @returns Object containing visibility analysis results
*/
export function useMessageContentAnalysis({ message }: UseMessageContentAnalysisProps) {
const hasVisibleContent = useMemo(() => {
if (!message.contentBlocks || message.contentBlocks.length === 0) return false
return message.contentBlocks.some((block) => {
if (block.type === 'text') {
const parsed = parseSpecialTags(block.content)
return parsed.cleanContent.trim().length > 0
}
return block.type === 'thinking' || block.type === 'tool_call'
})
}, [message.contentBlocks])
return {
hasVisibleContent,
}
}

View File

@@ -2,18 +2,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import type { ChatContext, CopilotMessage, MessageFileAttachment } from '@/stores/panel'
import type { CopilotMessage } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel'
const logger = createLogger('useMessageEditing')
/**
* Ref interface for UserInput component
*/
interface UserInputRef {
focus: () => void
}
/**
* Message truncation height in pixels
*/
@@ -39,8 +32,8 @@ interface UseMessageEditingProps {
setShowCheckpointDiscardModal: (show: boolean) => void
pendingEditRef: React.MutableRefObject<{
message: string
fileAttachments?: MessageFileAttachment[]
contexts?: ChatContext[]
fileAttachments?: any[]
contexts?: any[]
} | null>
/**
* When true, disables the internal document click-outside handler.
@@ -76,7 +69,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
const editContainerRef = useRef<HTMLDivElement>(null)
const messageContentRef = useRef<HTMLDivElement>(null)
const userInputRef = useRef<UserInputRef>(null)
const userInputRef = useRef<any>(null)
const { sendMessage, isSendingMessage, abortMessage, currentChat } = useCopilotStore()
@@ -128,11 +121,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
* Truncates messages after edited message and resends with same ID
*/
const performEdit = useCallback(
async (
editedMessage: string,
fileAttachments?: MessageFileAttachment[],
contexts?: ChatContext[]
) => {
async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => {
const currentMessages = messages
const editIndex = currentMessages.findIndex((m) => m.id === message.id)
@@ -145,7 +134,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
...message,
content: editedMessage,
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || message.contexts,
contexts: contexts || (message as any).contexts,
}
useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] })
@@ -164,7 +153,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
timestamp: m.timestamp,
...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
...(m.fileAttachments && { fileAttachments: m.fileAttachments }),
...(m.contexts && { contexts: m.contexts }),
...((m as any).contexts && { contexts: (m as any).contexts }),
})),
}),
})
@@ -175,7 +164,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
await sendMessage(editedMessage, {
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || message.contexts,
contexts: contexts || (message as any).contexts,
messageId: message.id,
queueIfBusy: false,
})
@@ -189,11 +178,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
* Checks for checkpoints and shows confirmation if needed
*/
const handleSubmitEdit = useCallback(
async (
editedMessage: string,
fileAttachments?: MessageFileAttachment[],
contexts?: ChatContext[]
) => {
async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => {
if (!editedMessage.trim()) return
if (isSendingMessage) {

View File

@@ -0,0 +1,138 @@
'use client'
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import type { CopilotMessage } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel'
const logger = createLogger('useMessageFeedback')
const WORKFLOW_TOOL_NAMES = ['edit_workflow']
interface UseMessageFeedbackProps {
setShowUpvoteSuccess: (show: boolean) => void
setShowDownvoteSuccess: (show: boolean) => void
}
/**
* Custom hook to handle message feedback (upvote/downvote)
*
* @param message - The copilot message
* @param messages - Array of all messages in the chat
* @param props - Success state setters from useSuccessTimers
* @returns Feedback management utilities
*/
export function useMessageFeedback(
message: CopilotMessage,
messages: CopilotMessage[],
props: UseMessageFeedbackProps
) {
const { setShowUpvoteSuccess, setShowDownvoteSuccess } = props
const { currentChat } = useCopilotStore()
/**
* Gets the full assistant response content from message
*/
const getFullAssistantContent = useCallback((message: CopilotMessage) => {
if (message.content?.trim()) {
return message.content
}
if (message.contentBlocks && message.contentBlocks.length > 0) {
return message.contentBlocks
.filter((block) => block.type === 'text')
.map((block) => block.content)
.join('')
}
return message.content || ''
}, [])
/**
* Finds the last user query before this assistant message
*/
const getLastUserQuery = useCallback(() => {
const messageIndex = messages.findIndex((msg) => msg.id === message.id)
if (messageIndex === -1) return null
for (let i = messageIndex - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
return messages[i].content
}
}
return null
}, [messages, message.id])
/**
* Submits feedback to the API
*/
const submitFeedback = useCallback(
async (isPositive: boolean) => {
if (!currentChat?.id) {
logger.error('No current chat ID available for feedback submission')
return
}
const userQuery = getLastUserQuery()
if (!userQuery) {
logger.error('No user query found for feedback submission')
return
}
const agentResponse = getFullAssistantContent(message)
if (!agentResponse.trim()) {
logger.error('No agent response content available for feedback submission')
return
}
try {
const requestBody = {
chatId: currentChat.id,
userQuery,
agentResponse,
isPositiveFeedback: isPositive,
}
const response = await fetch('/api/copilot/feedback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
if (!response.ok) {
throw new Error(`Failed to submit feedback: ${response.statusText}`)
}
await response.json()
} catch (error) {
logger.error('Error submitting feedback:', error)
}
},
[currentChat, getLastUserQuery, getFullAssistantContent, message]
)
/**
* Handles upvote action
*/
const handleUpvote = useCallback(async () => {
setShowDownvoteSuccess(false)
setShowUpvoteSuccess(true)
await submitFeedback(true)
}, [submitFeedback])
/**
* Handles downvote action
*/
const handleDownvote = useCallback(async () => {
setShowUpvoteSuccess(false)
setShowDownvoteSuccess(true)
await submitFeedback(false)
}, [submitFeedback])
return {
handleUpvote,
handleDownvote,
}
}

View File

@@ -0,0 +1,77 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
/**
* Duration to show success indicators (in milliseconds)
*/
const SUCCESS_DISPLAY_DURATION = 2000
/**
* Custom hook to manage auto-hiding success states
* Automatically hides success indicators after a set duration
*
* @returns Success state management utilities
*/
export function useSuccessTimers() {
const [showCopySuccess, setShowCopySuccess] = useState(false)
const [showUpvoteSuccess, setShowUpvoteSuccess] = useState(false)
const [showDownvoteSuccess, setShowDownvoteSuccess] = useState(false)
/**
* Auto-hide copy success indicator after duration
*/
useEffect(() => {
if (showCopySuccess) {
const timer = setTimeout(() => {
setShowCopySuccess(false)
}, SUCCESS_DISPLAY_DURATION)
return () => clearTimeout(timer)
}
}, [showCopySuccess])
/**
* Auto-hide upvote success indicator after duration
*/
useEffect(() => {
if (showUpvoteSuccess) {
const timer = setTimeout(() => {
setShowUpvoteSuccess(false)
}, SUCCESS_DISPLAY_DURATION)
return () => clearTimeout(timer)
}
}, [showUpvoteSuccess])
/**
* Auto-hide downvote success indicator after duration
*/
useEffect(() => {
if (showDownvoteSuccess) {
const timer = setTimeout(() => {
setShowDownvoteSuccess(false)
}, SUCCESS_DISPLAY_DURATION)
return () => clearTimeout(timer)
}
}, [showDownvoteSuccess])
/**
* Handles copy to clipboard action
* @param content - Content to copy to clipboard
*/
const handleCopy = useCallback((content: string) => {
navigator.clipboard.writeText(content)
setShowCopySuccess(true)
}, [])
return {
// State
showCopySuccess,
showUpvoteSuccess,
showDownvoteSuccess,
// Operations
handleCopy,
setShowUpvoteSuccess,
setShowDownvoteSuccess,
}
}

View File

@@ -1,8 +1,7 @@
export * from './chat-history-skeleton'
export * from './copilot-message'
export * from './plan-mode-section'
export * from './queued-messages'
export * from './todo-list'
export * from './tool-call'
export * from './user-input'
export * from './welcome'
export * from './copilot-message/copilot-message'
export * from './plan-mode-section/plan-mode-section'
export * from './queued-messages/queued-messages'
export * from './todo-list/todo-list'
export * from './tool-call/tool-call'
export * from './user-input/user-input'
export * from './welcome/welcome'

View File

@@ -29,7 +29,7 @@ import { Check, GripHorizontal, Pencil, X } from 'lucide-react'
import { Button, Textarea } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { cn } from '@/lib/core/utils/cn'
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
/**
* Shared border and background styles

View File

@@ -1,127 +0,0 @@
'use client'
import { ArrowUp, Image, Loader2 } from 'lucide-react'
import { Badge, Button } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { ModeSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mode-selector/mode-selector'
import { ModelSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/model-selector/model-selector'
interface BottomControlsProps {
mode: 'ask' | 'build' | 'plan'
onModeChange?: (mode: 'ask' | 'build' | 'plan') => void
selectedModel: string
onModelSelect: (model: string) => void
isNearTop: boolean
disabled: boolean
hideModeSelector: boolean
canSubmit: boolean
isLoading: boolean
isAborting: boolean
showAbortButton: boolean
onSubmit: () => void
onAbort: () => void
onFileSelect: () => void
}
/**
* Bottom controls section of the user input
* Contains mode selector, model selector, file attachment button, and submit/abort buttons
*/
export function BottomControls({
mode,
onModeChange,
selectedModel,
onModelSelect,
isNearTop,
disabled,
hideModeSelector,
canSubmit,
isLoading,
isAborting,
showAbortButton,
onSubmit,
onAbort,
onFileSelect,
}: BottomControlsProps) {
return (
<div className='flex items-center justify-between gap-2'>
{/* Left side: Mode Selector + Model Selector */}
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
{!hideModeSelector && (
<ModeSelector
mode={mode}
onModeChange={onModeChange}
isNearTop={isNearTop}
disabled={disabled}
/>
)}
<ModelSelector
selectedModel={selectedModel}
isNearTop={isNearTop}
onModelSelect={onModelSelect}
/>
</div>
{/* Right side: Attach Button + Send Button */}
<div className='flex flex-shrink-0 items-center gap-[10px]'>
<Badge
onClick={onFileSelect}
title='Attach file'
className={cn(
'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent',
disabled && 'cursor-not-allowed opacity-50'
)}
>
<Image className='!h-3.5 !w-3.5 scale-x-110' />
</Badge>
{showAbortButton ? (
<Button
onClick={onAbort}
disabled={isAborting}
className={cn(
'h-[20px] w-[20px] rounded-full border-0 p-0 transition-colors',
!isAborting
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
: 'bg-[var(--c-383838)] dark:bg-[var(--c-E0E0E0)]'
)}
title='Stop generation'
>
{isAborting ? (
<Loader2 className='block h-[13px] w-[13px] animate-spin text-white dark:text-black' />
) : (
<svg
className='block h-[13px] w-[13px] fill-white dark:fill-black'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
>
<rect x='4' y='4' width='16' height='16' rx='3' ry='3' />
</svg>
)}
</Button>
) : (
<Button
onClick={onSubmit}
disabled={!canSubmit}
className={cn(
'h-[22px] w-[22px] rounded-full border-0 p-0 transition-colors',
canSubmit
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
: 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
)}
>
{isLoading ? (
<Loader2 className='block h-3.5 w-3.5 animate-spin text-white dark:text-black' />
) : (
<ArrowUp
className='block h-3.5 w-3.5 text-white dark:text-black'
strokeWidth={2.25}
/>
)}
</Button>
)}
</div>
</div>
)
}

View File

@@ -1,7 +1,6 @@
export { AttachedFilesDisplay } from './attached-files-display'
export { BottomControls } from './bottom-controls'
export { ContextPills } from './context-pills'
export { type MentionFolderNav, MentionMenu } from './mention-menu'
export { ModeSelector } from './mode-selector'
export { ModelSelector } from './model-selector'
export { type SlashFolderNav, SlashMenu } from './slash-menu'
export { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
export { ContextPills } from './context-pills/context-pills'
export { type MentionFolderNav, MentionMenu } from './mention-menu/mention-menu'
export { ModeSelector } from './mode-selector/mode-selector'
export { ModelSelector } from './model-selector/model-selector'
export { type SlashFolderNav, SlashMenu } from './slash-menu/slash-menu'

View File

@@ -5,6 +5,5 @@ export { useMentionData } from './use-mention-data'
export { useMentionInsertHandlers } from './use-mention-insert-handlers'
export { useMentionKeyboard } from './use-mention-keyboard'
export { useMentionMenu } from './use-mention-menu'
export { useMentionSystem } from './use-mention-system'
export { useMentionTokens } from './use-mention-tokens'
export { useTextareaAutoResize } from './use-textarea-auto-resize'

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
escapeRegex,
filterOutContext,
isContextAlreadySelected,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
@@ -23,6 +22,9 @@ interface UseContextManagementProps {
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
const initializedRef = useRef(false)
const escapeRegex = useCallback((value: string) => {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}, [])
// Initialize with initial contexts when they're first provided (for edit mode)
useEffect(() => {

View File

@@ -151,7 +151,7 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
useShallow(useCallback((state) => Object.keys(state.blocks), []))
)
const registryWorkflows = useWorkflowRegistry(useShallow((state) => state.workflows))
const registryWorkflows = useWorkflowRegistry((state) => state.workflows)
const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
const isLoadingWorkflows =
hydrationPhase === 'idle' ||

View File

@@ -1,107 +0,0 @@
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import { useContextManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management'
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
import { useMentionData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
import { useMentionInsertHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-insert-handlers'
import { useMentionKeyboard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-keyboard'
import { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu'
import { useMentionTokens } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens'
import { useTextareaAutoResize } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-textarea-auto-resize'
import type { ChatContext } from '@/stores/panel'
interface UseMentionSystemProps {
message: string
setMessage: (message: string) => void
workflowId: string | null
workspaceId: string
userId?: string
panelWidth: number
disabled: boolean
isLoading: boolean
inputContainerRef: HTMLDivElement | null
initialContexts?: ChatContext[]
mentionFolderNav: MentionFolderNav | null
}
/**
* Composite hook that combines all mention-related hooks into a single interface.
* Reduces import complexity in components that need full mention functionality.
*
* @param props - Configuration for all mention system hooks
* @returns Combined interface for mention system functionality
*/
export function useMentionSystem({
message,
setMessage,
workflowId,
workspaceId,
userId,
panelWidth,
disabled,
isLoading,
inputContainerRef,
initialContexts,
mentionFolderNav,
}: UseMentionSystemProps) {
const contextManagement = useContextManagement({ message, initialContexts })
const mentionMenu = useMentionMenu({
message,
selectedContexts: contextManagement.selectedContexts,
onContextSelect: contextManagement.addContext,
onMessageChange: setMessage,
})
const mentionTokens = useMentionTokens({
message,
selectedContexts: contextManagement.selectedContexts,
mentionMenu,
setMessage,
setSelectedContexts: contextManagement.setSelectedContexts,
})
const { overlayRef } = useTextareaAutoResize({
message,
panelWidth,
selectedContexts: contextManagement.selectedContexts,
textareaRef: mentionMenu.textareaRef,
containerRef: inputContainerRef,
})
const mentionData = useMentionData({
workflowId,
workspaceId,
})
const fileAttachments = useFileAttachments({
userId,
disabled,
isLoading,
})
const insertHandlers = useMentionInsertHandlers({
mentionMenu,
workflowId,
selectedContexts: contextManagement.selectedContexts,
onContextAdd: contextManagement.addContext,
mentionFolderNav,
})
const mentionKeyboard = useMentionKeyboard({
mentionMenu,
mentionData,
insertHandlers,
mentionFolderNav,
})
return {
contextManagement,
mentionMenu,
mentionTokens,
overlayRef,
mentionData,
fileAttachments,
insertHandlers,
mentionKeyboard,
}
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react'
import { useCallback } from 'react'
import type { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu'
import type { ChatContext } from '@/stores/panel'
@@ -39,11 +39,11 @@ export function useMentionTokens({
setSelectedContexts,
}: UseMentionTokensProps) {
/**
* Memoized mention ranges - computed once when message or selectedContexts change.
* This prevents expensive O(n×m) string searches from running on every keystroke
* when other callbacks access the ranges.
* Computes all mention ranges in the message (both @mentions and /commands)
*
* @returns Array of mention ranges sorted by start position
*/
const memoizedMentionRanges = useMemo((): MentionRange[] => {
const computeMentionRanges = useCallback((): MentionRange[] => {
const ranges: MentionRange[] = []
if (!message || selectedContexts.length === 0) return ranges
@@ -93,45 +93,35 @@ export function useMentionTokens({
/**
* Finds a mention range containing the given position
*/
const computeMentionRanges = useCallback(
(): MentionRange[] => memoizedMentionRanges,
[memoizedMentionRanges]
)
/**
* Finds a mention range containing the given position.
* Uses memoized ranges directly for better performance.
*
* @param pos - Position to check
* @returns Mention range if found, undefined otherwise
*/
const findRangeContaining = useCallback(
(pos: number): MentionRange | undefined => {
return memoizedMentionRanges.find((r) => pos > r.start && pos < r.end)
const ranges = computeMentionRanges()
return ranges.find((r) => pos > r.start && pos < r.end)
},
[memoizedMentionRanges]
[computeMentionRanges]
)
/**
* Removes contexts for mention tokens that overlap with a text selection.
* Uses memoized ranges directly for better performance.
* Removes contexts for mention tokens that overlap with a text selection
*
* @param selStart - Selection start position
* @param selEnd - Selection end position
*/
const removeContextsInSelection = useCallback(
(selStart: number, selEnd: number) => {
const overlappingRanges = memoizedMentionRanges.filter(
(r) => !(selEnd <= r.start || selStart >= r.end)
)
const ranges = computeMentionRanges()
const overlappingRanges = ranges.filter((r) => !(selEnd <= r.start || selStart >= r.end))
if (overlappingRanges.length > 0) {
const labelsToRemove = new Set(overlappingRanges.map((r) => r.label))
setSelectedContexts((prev) => prev.filter((c) => !c.label || !labelsToRemove.has(c.label)))
}
},
[memoizedMentionRanges, setSelectedContexts]
[computeMentionRanges, setSelectedContexts]
)
/**

View File

@@ -9,19 +9,19 @@ import {
useState,
} from 'react'
import { createLogger } from '@sim/logger'
import { AtSign } from 'lucide-react'
import { ArrowUp, AtSign, Image, Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { createPortal } from 'react-dom'
import { Badge, Button, Textarea } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import type { CopilotModelId } from '@/lib/copilot/models'
import { cn } from '@/lib/core/utils/cn'
import {
AttachedFilesDisplay,
BottomControls,
ContextPills,
type MentionFolderNav,
MentionMenu,
ModelSelector,
ModeSelector,
type SlashFolderNav,
SlashMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
@@ -44,10 +44,6 @@ import {
useTextareaAutoResize,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import type { MessageFileAttachment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
import {
computeMentionHighlightRanges,
extractContextTokens,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { ChatContext } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel'
@@ -310,7 +306,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
size: f.size,
}))
onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts)
onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts as any)
const shouldClearInput = clearOnSubmit && !options.preserveInput && !overrideMessage
if (shouldClearInput) {
@@ -659,13 +655,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
[insertTriggerAndOpenMenu]
)
const handleModelSelect = useCallback(
(model: string) => {
setSelectedModel(model as CopilotModelId)
},
[setSelectedModel]
)
const canSubmit = message.trim().length > 0 && !disabled && !isLoading
const showAbortButton = isLoading && onAbort
@@ -681,17 +670,15 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
return <span>{displayText}</span>
}
const tokens = extractContextTokens(contexts)
const ranges = computeMentionHighlightRanges(message, tokens)
const elements: React.ReactNode[] = []
const ranges = mentionTokensWithContext.computeMentionRanges()
if (ranges.length === 0) {
const displayText = message.endsWith('\n') ? `${message}\u200B` : message
return <span>{displayText}</span>
}
const elements: React.ReactNode[] = []
let lastIndex = 0
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i]
@@ -700,12 +687,13 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
elements.push(<span key={`text-${i}-${lastIndex}-${range.start}`}>{before}</span>)
}
const mentionText = message.slice(range.start, range.end)
elements.push(
<span
key={`mention-${i}-${range.start}-${range.end}`}
className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'
>
{range.token}
{mentionText}
</span>
)
lastIndex = range.end
@@ -718,7 +706,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
}, [message, contextManagement.selectedContexts])
}, [message, contextManagement.selectedContexts, mentionTokensWithContext])
return (
<div
@@ -860,22 +848,87 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
</div>
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */}
<BottomControls
mode={mode}
onModeChange={onModeChange}
selectedModel={selectedModel}
onModelSelect={handleModelSelect}
isNearTop={isNearTop}
disabled={disabled}
hideModeSelector={hideModeSelector}
canSubmit={canSubmit}
isLoading={isLoading}
isAborting={isAborting}
showAbortButton={Boolean(showAbortButton)}
onSubmit={() => void handleSubmit()}
onAbort={handleAbort}
onFileSelect={fileAttachments.handleFileSelect}
/>
<div className='flex items-center justify-between gap-2'>
{/* Left side: Mode Selector + Model Selector */}
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
{!hideModeSelector && (
<ModeSelector
mode={mode}
onModeChange={onModeChange}
isNearTop={isNearTop}
disabled={disabled}
/>
)}
<ModelSelector
selectedModel={selectedModel}
isNearTop={isNearTop}
onModelSelect={(model: string) => setSelectedModel(model as any)}
/>
</div>
{/* Right side: Attach Button + Send Button */}
<div className='flex flex-shrink-0 items-center gap-[10px]'>
<Badge
onClick={fileAttachments.handleFileSelect}
title='Attach file'
className={cn(
'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent',
disabled && 'cursor-not-allowed opacity-50'
)}
>
<Image className='!h-3.5 !w-3.5 scale-x-110' />
</Badge>
{showAbortButton ? (
<Button
onClick={handleAbort}
disabled={isAborting}
className={cn(
'h-[20px] w-[20px] rounded-full border-0 p-0 transition-colors',
!isAborting
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
: 'bg-[var(--c-383838)] dark:bg-[var(--c-E0E0E0)]'
)}
title='Stop generation'
>
{isAborting ? (
<Loader2 className='block h-[13px] w-[13px] animate-spin text-white dark:text-black' />
) : (
<svg
className='block h-[13px] w-[13px] fill-white dark:fill-black'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
>
<rect x='4' y='4' width='16' height='16' rx='3' ry='3' />
</svg>
)}
</Button>
) : (
<Button
onClick={() => {
void handleSubmit()
}}
disabled={!canSubmit}
className={cn(
'h-[22px] w-[22px] rounded-full border-0 p-0 transition-colors',
canSubmit
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
: 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
)}
>
{isLoading ? (
<Loader2 className='block h-3.5 w-3.5 animate-spin text-white dark:text-black' />
) : (
<ArrowUp
className='block h-3.5 w-3.5 text-white dark:text-black'
strokeWidth={2.25}
/>
)}
</Button>
)}
</div>
</div>
{/* Hidden File Input - enabled during streaming so users can prepare images for the next message */}
<input

View File

@@ -1,4 +1,3 @@
import type { ReactNode } from 'react'
import {
FOLDER_CONFIGS,
type MentionFolderId,
@@ -6,102 +5,6 @@ import {
import type { MentionDataReturn } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
import type { ChatContext } from '@/stores/panel'
/**
* Escapes special regex characters in a string
* @param value - String to escape
* @returns Escaped string safe for use in RegExp
*/
export function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Extracts mention tokens from contexts for display/matching
* Filters out current_workflow contexts and builds prefixed labels
* @param contexts - Array of chat contexts
* @returns Array of prefixed token strings (e.g., "@workflow", "/web")
*/
export function extractContextTokens(contexts: ChatContext[]): string[] {
return contexts
.filter((c) => c.kind !== 'current_workflow' && c.label)
.map((c) => {
const prefix = c.kind === 'slash_command' ? '/' : '@'
return `${prefix}${c.label}`
})
}
/**
* Mention range for text highlighting
*/
export interface MentionHighlightRange {
start: number
end: number
token: string
}
/**
* Computes mention ranges in text for highlighting
* @param text - Text to search
* @param tokens - Prefixed tokens to find (e.g., "@workflow", "/web")
* @returns Array of ranges with start, end, and matched token
*/
export function computeMentionHighlightRanges(
text: string,
tokens: string[]
): MentionHighlightRange[] {
if (!tokens.length || !text) return []
const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g')
const ranges: MentionHighlightRange[] = []
let match: RegExpExecArray | null
while ((match = pattern.exec(text)) !== null) {
ranges.push({
start: match.index,
end: match.index + match[0].length,
token: match[0],
})
}
return ranges
}
/**
* Builds React nodes with highlighted mention tokens
* @param text - Text to render
* @param contexts - Chat contexts to highlight
* @param createHighlightSpan - Function to create highlighted span element
* @returns Array of React nodes with highlighted mentions
*/
export function buildMentionHighlightNodes(
text: string,
contexts: ChatContext[],
createHighlightSpan: (token: string, key: string) => ReactNode
): ReactNode[] {
const tokens = extractContextTokens(contexts)
if (!tokens.length) return [text]
const ranges = computeMentionHighlightRanges(text, tokens)
if (!ranges.length) return [text]
const nodes: ReactNode[] = []
let lastIndex = 0
for (const range of ranges) {
if (range.start > lastIndex) {
nodes.push(text.slice(lastIndex, range.start))
}
nodes.push(createHighlightSpan(range.token, `mention-${range.start}-${range.end}`))
lastIndex = range.end
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex))
}
return nodes
}
/**
* Gets the data array for a folder ID from mentionData.
* Uses FOLDER_CONFIGS as the source of truth for key mapping.

View File

@@ -24,7 +24,6 @@ import {
import { Trash } from '@/components/emcn/icons/trash'
import { cn } from '@/lib/core/utils/cn'
import {
ChatHistorySkeleton,
CopilotMessage,
PlanModeSection,
QueuedMessages,
@@ -41,7 +40,6 @@ import {
useTodoManagement,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks'
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import type { ChatContext } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -76,12 +74,10 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
const copilotContainerRef = useRef<HTMLDivElement>(null)
const cancelEditCallbackRef = useRef<(() => void) | null>(null)
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
const [isEditingMessage, setIsEditingMessage] = useState(false)
const [revertingMessageId, setRevertingMessageId] = useState<string | null>(null)
const [isHistoryDropdownOpen, setIsHistoryDropdownOpen] = useState(false)
// Derived state - editing when there's an editingMessageId
const isEditingMessage = editingMessageId !== null
const { activeWorkflowId } = useWorkflowRegistry()
const {
@@ -110,9 +106,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
areChatsFresh,
workflowId: copilotWorkflowId,
setPlanTodos,
closePlanTodos,
clearPlanArtifact,
savePlanArtifact,
setSelectedModel,
loadAutoAllowedTools,
} = useCopilotStore()
@@ -296,15 +292,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
}
}, [abortMessage, showPlanTodos])
/**
* Handles closing the plan todos section
* Calls store action and clears the todos
*/
const handleClosePlanTodos = useCallback(() => {
closePlanTodos()
setPlanTodos([])
}, [closePlanTodos, setPlanTodos])
/**
* Handles message submission to the copilot
* @param query - The message text to send
@@ -312,12 +299,13 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
* @param contexts - Optional context references
*/
const handleSubmit = useCallback(
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: ChatContext[]) => {
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
// Allow submission even when isSendingMessage - store will queue the message
if (!query || !activeWorkflowId) return
if (showPlanTodos) {
setPlanTodos([])
const store = useCopilotStore.getState()
store.setPlanTodos([])
}
try {
@@ -331,7 +319,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
logger.error('Failed to send message:', error)
}
},
[activeWorkflowId, sendMessage, showPlanTodos, setPlanTodos]
[activeWorkflowId, sendMessage, showPlanTodos]
)
/**
@@ -342,6 +330,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
const handleEditModeChange = useCallback(
(messageId: string, isEditing: boolean, cancelCallback?: () => void) => {
setEditingMessageId(isEditing ? messageId : null)
setIsEditingMessage(isEditing)
cancelEditCallbackRef.current = isEditing ? cancelCallback || null : null
logger.info('Edit mode changed', { messageId, isEditing, willDimMessages: isEditing })
},
@@ -386,6 +375,24 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
[handleHistoryDropdownOpenHook]
)
/**
* Skeleton loading component for chat history
*/
const ChatHistorySkeleton = () => (
<>
<PopoverSection>
<div className='h-3 w-12 animate-pulse rounded bg-muted/40' />
</PopoverSection>
<div className='flex flex-col gap-0.5'>
{[1, 2, 3].map((i) => (
<div key={i} className='flex h-[25px] items-center px-[6px]'>
<div className='h-3 w-full animate-pulse rounded bg-muted/40' />
</div>
))}
</div>
</>
)
return (
<>
<div
@@ -581,7 +588,11 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
<TodoList
todos={planTodos}
collapsed={todosCollapsed}
onClose={handleClosePlanTodos}
onClose={() => {
const store = useCopilotStore.getState()
store.closePlanTodos?.()
useCopilotStore.setState({ planTodos: [] })
}}
/>
</div>
)}

View File

@@ -80,25 +80,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
loadChats(false)
}
// Handle race condition: chats loaded for wrong workflow during initial load
// This happens when user navigates before initial loadChats completes
if (
activeWorkflowId &&
!isLoadingChats &&
chatsLoadedForWorkflow !== null &&
chatsLoadedForWorkflow !== activeWorkflowId &&
!isSendingMessage
) {
logger.info('Chats loaded for wrong workflow, reloading', {
loaded: chatsLoadedForWorkflow,
active: activeWorkflowId,
})
setIsInitialized(false)
lastWorkflowIdRef.current = activeWorkflowId
setCopilotWorkflowId(activeWorkflowId)
loadChats(false)
}
// Mark as initialized when chats are loaded for the active workflow
if (
activeWorkflowId &&

View File

@@ -11,6 +11,8 @@ import {
ButtonGroupItem,
Checkbox,
Code,
Combobox,
type ComboboxOption,
Input,
Label,
TagInput,
@@ -269,6 +271,14 @@ export function A2aDeploy({
onNeedsRepublishChange?.(!!needsRepublish)
}, [needsRepublish, onNeedsRepublishChange])
const authSchemeOptions: ComboboxOption[] = useMemo(
() => [
{ label: 'API Key', value: 'apiKey' },
{ label: 'None (Public)', value: 'none' },
],
[]
)
const canSave = name.trim().length > 0 && description.trim().length > 0
useEffect(() => {
onCanSaveChange?.(canSave)
@@ -748,18 +758,17 @@ console.log(data);`
/>
</div>
{/* Access */}
{/* Authentication */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Access
Authentication
</Label>
<ButtonGroup
<Combobox
options={authSchemeOptions}
value={authScheme}
onValueChange={(value) => setAuthScheme(value as AuthScheme)}
>
<ButtonGroupItem value='apiKey'>API Key</ButtonGroupItem>
<ButtonGroupItem value='none'>Public</ButtonGroupItem>
</ButtonGroup>
onChange={(v) => setAuthScheme(v as AuthScheme)}
placeholder='Select authentication...'
/>
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
{authScheme === 'none'
? 'Anyone can call this agent without authentication'

View File

@@ -409,11 +409,7 @@ export function ChatDeploy({
<ModalHeader>Delete Chat</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{existingChat?.title || 'this chat'}
</span>
?{' '}
Are you sure you want to delete this chat?{' '}
<span className='text-[var(--text-error)]'>
This will remove the chat at "{getEmailDomain()}/chat/{existingChat?.identifier}"
and make it unavailable to all users.
@@ -428,7 +424,7 @@ export function ChatDeploy({
>
Cancel
</Button>
<Button variant='destructive' onClick={handleDelete} disabled={isDeleting}>
<Button variant='default' onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>

View File

@@ -375,11 +375,8 @@ export function TemplateDeploy({
<ModalHeader>Delete Template</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{existingTemplate?.name || formData.name || 'this template'}
</span>
? <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
Are you sure you want to delete this template?{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>

View File

@@ -846,11 +846,7 @@ export function DeployModal({
<ModalHeader>Delete A2A Agent</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{existingA2aAgent?.name || 'this agent'}
</span>
?{' '}
Are you sure you want to delete this agent?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove the agent configuration.
</span>

View File

@@ -1,5 +1,5 @@
import type { ReactElement } from 'react'
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { Check, Copy, Wand2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import 'prismjs/components/prism-python'
@@ -170,7 +170,7 @@ interface CodeProps {
hideInternalWand?: boolean
}
export const Code = memo(function Code({
export function Code({
blockId,
subBlockId,
placeholder = 'Write JavaScript...',
@@ -206,8 +206,6 @@ export const Code = memo(function Code({
const handleGeneratedContentRef = useRef<(generatedCode: string) => void>(() => {})
const handleStreamChunkRef = useRef<(chunk: string) => void>(() => {})
const hasEditedSinceFocusRef = useRef(false)
const codeRef = useRef(code)
codeRef.current = code
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const emitTagSelection = useTagSelection(blockId, subBlockId)
@@ -309,18 +307,25 @@ export const Code = memo(function Code({
? getDefaultValueString()
: storeValue
const lastValidationStatus = useRef<boolean>(true)
useEffect(() => {
if (!onValidationChange) return
const isValid = !shouldValidateJson || isValidJson
const nextStatus = shouldValidateJson ? isValidJson : true
if (lastValidationStatus.current === nextStatus) {
return
}
if (isValid) {
onValidationChange(true)
lastValidationStatus.current = nextStatus
if (!shouldValidateJson) {
onValidationChange(nextStatus)
return
}
const timeoutId = setTimeout(() => {
onValidationChange(false)
onValidationChange(nextStatus)
}, 150)
return () => clearTimeout(timeoutId)
@@ -332,7 +337,7 @@ export const Code = memo(function Code({
}
handleStreamChunkRef.current = (chunk: string) => {
setCode((prev: string) => prev + chunk)
setCode((prev) => prev + chunk)
}
handleGeneratedContentRef.current = (generatedCode: string) => {
@@ -429,12 +434,12 @@ export const Code = memo(function Code({
`
document.body.appendChild(tempContainer)
lines.forEach((line: string) => {
lines.forEach((line) => {
const lineDiv = document.createElement('div')
if (line.includes('<') && line.includes('>')) {
const parts = line.split(/(<[^>]+>)/g)
parts.forEach((part: string) => {
parts.forEach((part) => {
const span = document.createElement('span')
span.textContent = part
lineDiv.appendChild(span)
@@ -467,6 +472,7 @@ export const Code = memo(function Code({
}
}, [code])
// Event Handlers
/**
* Handles drag-and-drop events for inserting reference tags into the code editor.
* @param e - The drag event
@@ -494,6 +500,7 @@ export const Code = memo(function Code({
textarea.selectionStart = newCursorPosition
textarea.selectionEnd = newCursorPosition
// Show tag dropdown after cursor is positioned
setShowTags(true)
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
@@ -552,45 +559,44 @@ export const Code = memo(function Code({
}
}
// Helper Functions
/**
* Determines whether a `<...>` segment should be highlighted as a reference.
* @param part - The code segment to check
* @returns True if the segment should be highlighted as a reference
*/
const shouldHighlightReference = useCallback(
(part: string): boolean => {
if (!part.startsWith('<') || !part.endsWith('>')) {
return false
}
const shouldHighlightReference = (part: string): boolean => {
if (!part.startsWith('<') || !part.endsWith('>')) {
return false
}
if (!isLikelyReferenceSegment(part)) {
return false
}
if (!isLikelyReferenceSegment(part)) {
return false
}
const split = splitReferenceSegment(part)
if (!split) {
return false
}
const split = splitReferenceSegment(part)
if (!split) {
return false
}
const reference = split.reference
const reference = split.reference
if (!accessiblePrefixes) {
return true
}
if (!accessiblePrefixes) {
return true
}
const inner = reference.slice(1, -1)
const [prefix] = inner.split('.')
const normalizedPrefix = normalizeName(prefix)
const inner = reference.slice(1, -1)
const [prefix] = inner.split('.')
const normalizedPrefix = normalizeName(prefix)
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
return true
}
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
return true
}
return accessiblePrefixes.has(normalizedPrefix)
},
[accessiblePrefixes]
)
return accessiblePrefixes.has(normalizedPrefix)
}
// Expose wand control handlers to parent via ref
useImperativeHandle(
wandControlRef,
() => ({
@@ -603,62 +609,6 @@ export const Code = memo(function Code({
[generateCodeStream, isPromptVisible, isAiStreaming]
)
const highlightCode = useMemo(
() => createHighlightFunction(effectiveLanguage, shouldHighlightReference),
[effectiveLanguage, shouldHighlightReference]
)
const handleValueChange = useCallback(
(newCode: string) => {
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
hasEditedSinceFocusRef.current = true
setCode(newCode)
setStoreValue(newCode)
const textarea = editorRef.current?.querySelector('textarea')
if (textarea) {
const pos = textarea.selectionStart
setCursorPosition(pos)
const tagTrigger = checkTagTrigger(newCode, pos)
setShowTags(tagTrigger.show)
if (!tagTrigger.show) {
setActiveSourceBlockId(null)
}
const envVarTrigger = checkEnvVarTrigger(newCode, pos)
setShowEnvVars(envVarTrigger.show)
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
}
}
},
[isAiStreaming, isPreview, disabled, readOnly, setStoreValue]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement | HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
setShowTags(false)
setShowEnvVars(false)
}
if (isAiStreaming) {
e.preventDefault()
}
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) {
e.preventDefault()
}
},
[isAiStreaming]
)
const handleEditorFocus = useCallback(() => {
hasEditedSinceFocusRef.current = false
if (!isPreview && !disabled && !readOnly && codeRef.current.trim() === '') {
setShowTags(true)
setCursorPosition(0)
}
}, [isPreview, disabled, readOnly])
/**
* Renders the line numbers, aligned with wrapped visual lines and highlighting the active line.
* @returns Array of React elements representing the line numbers
@@ -667,7 +617,7 @@ export const Code = memo(function Code({
const numbers: ReactElement[] = []
let lineNumber = 1
visualLineHeights.forEach((height: number) => {
visualLineHeights.forEach((height) => {
const isActive = lineNumber === activeLineNumber
numbers.push(
<div
@@ -774,10 +724,50 @@ export const Code = memo(function Code({
<Editor
value={code}
onValueChange={handleValueChange}
onKeyDown={handleKeyDown}
onFocus={handleEditorFocus}
highlight={highlightCode}
onValueChange={(newCode) => {
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
hasEditedSinceFocusRef.current = true
setCode(newCode)
setStoreValue(newCode)
const textarea = editorRef.current?.querySelector('textarea')
if (textarea) {
const pos = textarea.selectionStart
setCursorPosition(pos)
const tagTrigger = checkTagTrigger(newCode, pos)
setShowTags(tagTrigger.show)
if (!tagTrigger.show) {
setActiveSourceBlockId(null)
}
const envVarTrigger = checkEnvVarTrigger(newCode, pos)
setShowEnvVars(envVarTrigger.show)
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
}
}
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setShowTags(false)
setShowEnvVars(false)
}
if (isAiStreaming) {
e.preventDefault()
}
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) {
e.preventDefault()
}
}}
onFocus={() => {
hasEditedSinceFocusRef.current = false
// Show tag dropdown on focus when code is empty
if (!isPreview && !disabled && !readOnly && code.trim() === '') {
setShowTags(true)
setCursorPosition(0)
}
}}
highlight={createHighlightFunction(effectiveLanguage, shouldHighlightReference)}
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
/>
@@ -820,4 +810,4 @@ export const Code = memo(function Code({
</CodeEditor.Container>
</>
)
})
}

View File

@@ -1,5 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useReactFlow } from 'reactflow'
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
@@ -72,7 +71,7 @@ interface ComboBoxProps {
dependsOn?: SubBlockConfig['dependsOn']
}
export const ComboBox = memo(function ComboBox({
export function ComboBox({
options,
defaultValue,
blockId,
@@ -113,8 +112,7 @@ export const ComboBox = memo(function ComboBox({
)
},
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
),
isEqual
)
)
// State management
@@ -283,17 +281,34 @@ export const ComboBox = memo(function ComboBox({
setStoreInitialized(true)
}, [])
// Check if current value is valid (exists in allowed options)
const isValueValid = useMemo(() => {
if (value === null || value === undefined) return false
return evaluatedOptions.some((opt) => getOptionValue(opt) === value)
}, [value, evaluatedOptions, getOptionValue])
// Set default value once store is initialized and permissions are loaded
// Also reset if current value becomes invalid (e.g., provider was blocked)
useEffect(() => {
if (isPermissionLoading) return
if (!storeInitialized) return
if (defaultOptionValue === undefined) return
// Only set default when no value exists (initial block add)
if (value === null || value === undefined) {
const needsDefault = value === null || value === undefined
const needsReset = subBlockId === 'model' && value && !isValueValid
if (needsDefault || needsReset) {
setStoreValue(defaultOptionValue)
}
}, [storeInitialized, value, defaultOptionValue, setStoreValue, isPermissionLoading])
}, [
storeInitialized,
value,
defaultOptionValue,
setStoreValue,
isPermissionLoading,
subBlockId,
isValueValid,
])
// Clear fetched options and hydrated option when dependencies change
useEffect(() => {
@@ -422,18 +437,6 @@ export const ComboBox = memo(function ComboBox({
[reactFlowInstance]
)
/**
* Handles combobox open state changes to trigger option fetching
*/
const handleOpenChange = useCallback(
(open: boolean) => {
if (open) {
void fetchOptionsIfNeeded()
}
},
[fetchOptionsIfNeeded]
)
/**
* Gets the icon for the currently selected option
*/
@@ -463,75 +466,6 @@ export const ComboBox = memo(function ComboBox({
)
}, [inputValue, accessiblePrefixes, selectedOption, selectedOptionIcon])
const ctrlOnChangeRef = useRef<
((e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => void) | null
>(null)
const onDropRef = useRef<
((e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void) | null
>(null)
const onDragOverRef = useRef<
((e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void) | null
>(null)
const inputRefFromController = useRef<HTMLInputElement | null>(null)
const comboboxOnChange = useCallback(
(newValue: string) => {
const matchedComboboxOption = comboboxOptions.find((option) => option.value === newValue)
if (matchedComboboxOption) {
setInputValue(matchedComboboxOption.label)
} else {
setInputValue(newValue)
}
// Use controller's handler so env vars, tags, and DnD still work
const syntheticEvent = {
target: { value: newValue, selectionStart: newValue.length },
} as React.ChangeEvent<HTMLInputElement>
ctrlOnChangeRef.current?.(syntheticEvent)
},
[comboboxOptions, setInputValue]
)
const comboboxInputProps = useMemo(
() => ({
onDrop: ((e: React.DragEvent<HTMLInputElement>) => {
onDropRef.current?.(e)
}) as (e: React.DragEvent<HTMLInputElement>) => void,
onDragOver: ((e: React.DragEvent<HTMLInputElement>) => {
onDragOverRef.current?.(e)
}) as (e: React.DragEvent<HTMLInputElement>) => void,
onWheel: handleWheel,
autoComplete: 'off' as const,
}),
[handleWheel]
)
// Stable onChange for SubBlockInputController
const controllerOnChange = useCallback(
(newValue: string) => {
if (isPreview) {
return
}
const matchedOption = evaluatedOptions.find((option) => {
if (typeof option === 'string') {
return option === newValue
}
return option.id === newValue
})
// If a matching option is found, store its ID; otherwise store the raw value
// (allows expressions like <block.output> to be entered directly)
const nextValue = matchedOption
? typeof matchedOption === 'string'
? matchedOption
: matchedOption.id
: newValue
setStoreValue(nextValue)
},
[isPreview, evaluatedOptions, setStoreValue]
)
return (
<div className='relative w-full'>
<SubBlockInputController
@@ -539,43 +473,76 @@ export const ComboBox = memo(function ComboBox({
subBlockId={subBlockId}
config={config}
value={propValue}
onChange={controllerOnChange}
onChange={(newValue) => {
if (isPreview) {
return
}
const matchedOption = evaluatedOptions.find((option) => {
if (typeof option === 'string') {
return option === newValue
}
return option.id === newValue
})
// If a matching option is found, store its ID; otherwise store the raw value
// (allows expressions like <block.output> to be entered directly)
const nextValue = matchedOption
? typeof matchedOption === 'string'
? matchedOption
: matchedOption.id
: newValue
setStoreValue(nextValue)
}}
isPreview={isPreview}
disabled={disabled}
previewValue={previewValue}
>
{({ ref, onChange: ctrlOnChange, onDrop, onDragOver }) => {
// Update refs with latest handlers from render prop
ctrlOnChangeRef.current = ctrlOnChange
onDropRef.current = onDrop
onDragOverRef.current = onDragOver
// Store the input ref for passing to Combobox
if (ref.current) {
inputRefFromController.current = ref.current as HTMLInputElement
}
{({ ref, onChange: ctrlOnChange, onDrop, onDragOver }) => (
<Combobox
options={comboboxOptions}
value={inputValue}
selectedValue={value ?? ''}
onChange={(newValue) => {
const matchedComboboxOption = comboboxOptions.find(
(option) => option.value === newValue
)
if (matchedComboboxOption) {
setInputValue(matchedComboboxOption.label)
} else {
setInputValue(newValue)
}
return (
<Combobox
options={comboboxOptions}
value={inputValue}
selectedValue={value ?? ''}
onChange={comboboxOnChange}
placeholder={placeholder}
disabled={disabled}
editable
overlayContent={overlayContent}
inputRef={ref as React.RefObject<HTMLInputElement>}
filterOptions
searchable={config.searchable}
className={cn('allow-scroll overflow-x-auto', selectedOptionIcon && 'pl-[28px]')}
inputProps={comboboxInputProps}
isLoading={isLoadingOptions}
error={fetchError}
onOpenChange={handleOpenChange}
/>
)
}}
// Use controller's handler so env vars, tags, and DnD still work
const syntheticEvent = {
target: { value: newValue, selectionStart: newValue.length },
} as React.ChangeEvent<HTMLInputElement>
ctrlOnChange(syntheticEvent)
}}
placeholder={placeholder}
disabled={disabled}
editable
overlayContent={overlayContent}
inputRef={ref as React.RefObject<HTMLInputElement>}
filterOptions
searchable={config.searchable}
className={cn('allow-scroll overflow-x-auto', selectedOptionIcon && 'pl-[28px]')}
inputProps={{
onDrop: onDrop as (e: React.DragEvent<HTMLInputElement>) => void,
onDragOver: onDragOver as (e: React.DragEvent<HTMLInputElement>) => void,
onWheel: handleWheel,
autoComplete: 'off',
}}
isLoading={isLoadingOptions}
error={fetchError}
onOpenChange={(open) => {
if (open) {
void fetchOptionsIfNeeded()
}
}}
/>
)}
</SubBlockInputController>
</div>
)
})
}

View File

@@ -41,7 +41,6 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'https://www.googleapis.com/auth/calendar': 'View and manage calendar',
'https://www.googleapis.com/auth/userinfo.email': 'View email address',
'https://www.googleapis.com/auth/userinfo.profile': 'View basic profile info',
'https://www.googleapis.com/auth/forms.body': 'View and manage Google Forms',
'https://www.googleapis.com/auth/forms.responses.readonly': 'View responses to Google Forms',
'https://www.googleapis.com/auth/ediscovery': 'Access Google Vault for eDiscovery',
'https://www.googleapis.com/auth/devstorage.read_only': 'Read files from Google Cloud Storage',

View File

@@ -15,7 +15,6 @@ interface DocumentSelectorProps {
onDocumentSelect?: (documentId: string) => void
isPreview?: boolean
previewValue?: string | null
previewContextValues?: Record<string, unknown>
}
export function DocumentSelector({
@@ -25,15 +24,9 @@ export function DocumentSelector({
onDocumentSelect,
isPreview = false,
previewValue,
previewContextValues,
}: DocumentSelectorProps) {
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled,
isPreview,
previewContextValues,
})
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
const normalizedKnowledgeBaseId =
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue

View File

@@ -37,7 +37,6 @@ interface DocumentTagEntryProps {
disabled?: boolean
isPreview?: boolean
previewValue?: any
previewContextValues?: Record<string, unknown>
}
/**
@@ -57,7 +56,6 @@ export function DocumentTagEntry({
disabled = false,
isPreview = false,
previewValue,
previewContextValues,
}: DocumentTagEntryProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlock.id)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
@@ -76,12 +74,8 @@ export function DocumentTagEntry({
disabled,
})
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
const knowledgeBaseId =
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue
: null
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseId = knowledgeBaseIdValue || null
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const emitTagSelection = useTagSelection(blockId, subBlock.id)
@@ -137,16 +131,11 @@ export function DocumentTagEntry({
}
/**
* Removes a tag by ID, or resets it if it's the last one
* Removes a tag by ID (prevents removing the last tag)
*/
const removeTag = (id: string) => {
if (isReadOnly) return
if (tags.length === 1) {
// Reset the last tag instead of removing it
updateTags([createDefaultTag()])
} else {
updateTags(tags.filter((t) => t.id !== id))
}
if (isReadOnly || tags.length === 1) return
updateTags(tags.filter((t) => t.id !== id))
}
/**
@@ -233,7 +222,6 @@ export function DocumentTagEntry({
/**
* Renders the tag header with name, badge, and action buttons
* Shows tag name only when collapsed (as summary), generic label when expanded
*/
const renderTagHeader = (tag: DocumentTag, index: number) => (
<div
@@ -242,11 +230,9 @@ export function DocumentTagEntry({
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{tag.collapsed ? tag.tagName || `Tag ${index + 1}` : `Tag ${index + 1}`}
{tag.tagName || `Tag ${index + 1}`}
</span>
{tag.collapsed && tag.tagName && (
<Badge size='sm'>{FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}</Badge>
)}
{tag.tagName && <Badge size='sm'>{FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}</Badge>}
</div>
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
<Button
@@ -261,7 +247,7 @@ export function DocumentTagEntry({
<Button
variant='ghost'
onClick={() => removeTag(tag.id)}
disabled={isReadOnly}
disabled={isReadOnly || tags.length === 1}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />
@@ -355,7 +341,7 @@ export function DocumentTagEntry({
const tagOptions: ComboboxOption[] = selectableTags.map((t) => ({
value: t.displayName,
label: t.displayName,
label: `${t.displayName} (${FIELD_TYPE_LABELS[t.fieldType] || 'Text'})`,
}))
return (

View File

@@ -1,5 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Badge } from '@/components/emcn'
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
@@ -69,7 +68,7 @@ interface DropdownProps {
* - Special handling for dataMode subblock to convert between JSON and structured formats
* - Integrates with the workflow state management system
*/
export const Dropdown = memo(function Dropdown({
export function Dropdown({
options,
defaultValue,
blockId,
@@ -111,8 +110,7 @@ export const Dropdown = memo(function Dropdown({
)
},
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
),
isEqual
)
)
const [storeInitialized, setStoreInitialized] = useState(false)
@@ -163,18 +161,6 @@ export const Dropdown = memo(function Dropdown({
}
}, [fetchOptions, blockId, subBlockId, isPreview, disabled])
/**
* Handles combobox open state changes to trigger option fetching
*/
const handleOpenChange = useCallback(
(open: boolean) => {
if (open) {
void fetchOptionsIfNeeded()
}
},
[fetchOptionsIfNeeded]
)
const evaluatedOptions = useMemo(() => {
return typeof options === 'function' ? options() : options
}, [options])
@@ -485,7 +471,11 @@ export const Dropdown = memo(function Dropdown({
placeholder={placeholder}
disabled={disabled}
editable={false}
onOpenChange={handleOpenChange}
onOpenChange={(open) => {
if (open) {
void fetchOptionsIfNeeded()
}
}}
overlayContent={multiSelectOverlay}
multiSelect={multiSelect}
isLoading={isLoadingOptions}
@@ -494,4 +484,4 @@ export const Dropdown = memo(function Dropdown({
searchPlaceholder='Search...'
/>
)
})
}

View File

@@ -40,7 +40,6 @@ interface KnowledgeTagFiltersProps {
disabled?: boolean
isPreview?: boolean
previewValue?: string | null
previewContextValues?: Record<string, unknown>
}
/**
@@ -61,19 +60,14 @@ export function KnowledgeTagFilters({
disabled = false,
isPreview = false,
previewValue,
previewContextValues,
}: KnowledgeTagFiltersProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
const emitTagSelection = useTagSelection(blockId, subBlock.id)
const valueInputRefs = useRef<Record<string, HTMLInputElement>>({})
const overlayRefs = useRef<Record<string, HTMLDivElement>>({})
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
const knowledgeBaseId =
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue
: null
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseId = knowledgeBaseIdValue || null
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
@@ -129,16 +123,11 @@ export function KnowledgeTagFilters({
}
/**
* Removes a filter by ID, or resets it if it's the last one
* Removes a filter by ID (prevents removing the last filter)
*/
const removeFilter = (id: string) => {
if (isReadOnly) return
if (filters.length === 1) {
// Reset the last filter instead of removing it
updateFilters([createDefaultFilter()])
} else {
updateFilters(filters.filter((f) => f.id !== id))
}
if (isReadOnly || filters.length === 1) return
updateFilters(filters.filter((f) => f.id !== id))
}
/**
@@ -226,7 +215,6 @@ export function KnowledgeTagFilters({
/**
* Renders the filter header with name, badge, and action buttons
* Shows tag name only when collapsed (as summary), generic label when expanded
*/
const renderFilterHeader = (filter: TagFilter, index: number) => (
<div
@@ -235,11 +223,9 @@ export function KnowledgeTagFilters({
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{filter.collapsed ? filter.tagName || `Filter ${index + 1}` : `Filter ${index + 1}`}
{filter.tagName || `Filter ${index + 1}`}
</span>
{filter.collapsed && filter.tagName && (
<Badge size='sm'>{FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}</Badge>
)}
{filter.tagName && <Badge size='sm'>{FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}</Badge>}
</div>
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
<Button variant='ghost' onClick={addFilter} disabled={isReadOnly} className='h-auto p-0'>
@@ -249,7 +235,7 @@ export function KnowledgeTagFilters({
<Button
variant='ghost'
onClick={() => removeFilter(filter.id)}
disabled={isReadOnly}
disabled={isReadOnly || filters.length === 1}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />
@@ -338,7 +324,7 @@ export function KnowledgeTagFilters({
const renderFilterContent = (filter: TagFilter) => {
const tagOptions: ComboboxOption[] = tagDefinitions.map((tag) => ({
value: tag.displayName,
label: tag.displayName,
label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`,
}))
const operators = getOperatorsForFieldType(filter.fieldType)

View File

@@ -1,17 +1,281 @@
import { useCallback, useMemo } from 'react'
import type { RefObject } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { Combobox, Label, Slider, Switch } from '@/components/emcn/components'
import { Combobox, Input, Label, Slider, Switch, Textarea } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
import { LongInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input'
import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import {
checkTagTrigger,
TagDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
import { formatParameterLabel } from '@/tools/params'
const logger = createLogger('McpDynamicArgs')
interface McpInputWithTagsProps {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
isPassword?: boolean
blockId: string
accessiblePrefixes?: Set<string>
}
function McpInputWithTags({
value,
onChange,
placeholder,
disabled,
isPassword,
blockId,
accessiblePrefixes,
}: McpInputWithTagsProps) {
const [showTags, setShowTags] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const inputNameRef = useRef(`mcp_input_${Math.random()}`)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
onChange(newValue)
setCursorPosition(newCursorPosition)
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data.type !== 'connectionBlock') return
const dropPosition = inputRef.current?.selectionStart ?? value.length ?? 0
const currentValue = value ?? ''
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
onChange(newValue)
setCursorPosition(dropPosition + 1)
setShowTags(true)
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
setTimeout(() => {
if (inputRef.current) {
inputRef.current.selectionStart = dropPosition + 1
inputRef.current.selectionEnd = dropPosition + 1
}
}, 0)
} catch (error) {
logger.error('Failed to parse drop data:', { error })
}
}
const handleDragOver = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault()
}
const handleTagSelect = (newValue: string) => {
onChange(newValue)
setShowTags(false)
setActiveSourceBlockId(null)
}
return (
<div className='relative'>
<div className='relative'>
<Input
ref={inputRef}
type={isPassword ? 'password' : 'text'}
value={value || ''}
onChange={handleChange}
onDrop={handleDrop}
onDragOver={handleDragOver}
placeholder={placeholder}
disabled={disabled}
name={inputNameRef.current}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
data-form-type='other'
data-lpignore='true'
data-1p-ignore
readOnly
onFocus={(e) => {
e.currentTarget.removeAttribute('readOnly')
// Show tag dropdown on focus when input is empty
if (!disabled && (value?.trim() === '' || !value)) {
setShowTags(true)
setCursorPosition(0)
}
}}
className={cn(!isPassword && 'text-transparent caret-foreground')}
/>
{!isPassword && (
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'>
<div className='whitespace-pre'>
{formatDisplayText(value?.toString() || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
)}
</div>
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
setActiveSourceBlockId(null)
}}
inputRef={inputRef as RefObject<HTMLInputElement>}
/>
</div>
)
}
interface McpTextareaWithTagsProps {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
blockId: string
accessiblePrefixes?: Set<string>
rows?: number
}
function McpTextareaWithTags({
value,
onChange,
placeholder,
disabled,
blockId,
accessiblePrefixes,
rows = 4,
}: McpTextareaWithTagsProps) {
const [showTags, setShowTags] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const textareaNameRef = useRef(`mcp_textarea_${Math.random()}`)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
onChange(newValue)
setCursorPosition(newCursorPosition)
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
const handleDrop = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data.type !== 'connectionBlock') return
const dropPosition = textareaRef.current?.selectionStart ?? value.length ?? 0
const currentValue = value ?? ''
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
onChange(newValue)
setCursorPosition(dropPosition + 1)
setShowTags(true)
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = dropPosition + 1
textareaRef.current.selectionEnd = dropPosition + 1
}
}, 0)
} catch (error) {
logger.error('Failed to parse drop data:', { error })
}
}
const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault()
}
const handleTagSelect = (newValue: string) => {
onChange(newValue)
setShowTags(false)
setActiveSourceBlockId(null)
}
return (
<div className='relative'>
<Textarea
ref={textareaRef}
value={value || ''}
onChange={handleChange}
onDrop={handleDrop}
onDragOver={handleDragOver}
onFocus={() => {
// Show tag dropdown on focus when input is empty
if (!disabled && (value?.trim() === '' || !value)) {
setShowTags(true)
setCursorPosition(0)
}
}}
placeholder={placeholder}
disabled={disabled}
rows={rows}
name={textareaNameRef.current}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
data-form-type='other'
data-lpignore='true'
data-1p-ignore
className={cn('min-h-[80px] resize-none text-transparent caret-foreground')}
/>
<div className='pointer-events-none absolute inset-0 overflow-auto whitespace-pre-wrap break-words px-[8px] py-[8px] font-medium font-sans text-sm'>
{formatDisplayText(value || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
setActiveSourceBlockId(null)
}}
inputRef={textareaRef as RefObject<HTMLTextAreaElement>}
/>
</div>
)
}
interface McpDynamicArgsProps {
blockId: string
subBlockId: string
@@ -20,27 +284,6 @@ interface McpDynamicArgsProps {
previewValue?: any
}
/**
* Creates a minimal SubBlockConfig for MCP tool parameters
*/
function createParamConfig(
paramName: string,
paramSchema: any,
inputType: 'long-input' | 'short-input'
): SubBlockConfig {
const placeholder =
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description || `Enter ${formatParameterLabel(paramName).toLowerCase()}`
return {
id: paramName,
type: inputType,
title: formatParameterLabel(paramName),
placeholder,
}
}
export function McpDynamicArgs({
blockId,
subBlockId,
@@ -54,6 +297,7 @@ export function McpDynamicArgs({
const [selectedTool] = useSubBlockValue(blockId, 'tool')
const [cachedSchema] = useSubBlockValue(blockId, '_toolSchema')
const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool)
const toolSchema = cachedSchema || selectedToolConfig?.inputSchema
@@ -64,7 +308,7 @@ export function McpDynamicArgs({
try {
return JSON.parse(previewValue)
} catch (error) {
logger.warn('Failed to parse preview value as JSON:', { error })
console.warn('Failed to parse preview value as JSON:', error)
return previewValue
}
}
@@ -74,7 +318,7 @@ export function McpDynamicArgs({
try {
return JSON.parse(toolArgs)
} catch (error) {
logger.warn('Failed to parse toolArgs as JSON:', { error })
console.warn('Failed to parse toolArgs as JSON:', error)
return {}
}
}
@@ -216,23 +460,24 @@ export function McpDynamicArgs({
)
}
case 'long-input': {
const config = createParamConfig(paramName, paramSchema, 'long-input')
case 'long-input':
return (
<LongInput
<McpTextareaWithTags
key={`${paramName}-long`}
blockId={blockId}
subBlockId={`_mcp_${paramName}`}
config={config}
placeholder={config.placeholder}
rows={4}
value={value || ''}
onChange={(newValue) => updateParameter(paramName, newValue)}
isPreview={isPreview}
placeholder={
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description ||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
}
disabled={disabled}
blockId={blockId}
accessiblePrefixes={accessiblePrefixes}
rows={4}
/>
)
}
default: {
const isPassword =
@@ -240,16 +485,10 @@ export function McpDynamicArgs({
paramName.toLowerCase().includes('password') ||
paramName.toLowerCase().includes('token')
const isNumeric = paramSchema.type === 'number' || paramSchema.type === 'integer'
const config = createParamConfig(paramName, paramSchema, 'short-input')
return (
<ShortInput
<McpInputWithTags
key={`${paramName}-short`}
blockId={blockId}
subBlockId={`_mcp_${paramName}`}
config={config}
placeholder={config.placeholder}
password={isPassword}
value={value?.toString() || ''}
onChange={(newValue) => {
let processedValue: any = newValue
@@ -267,8 +506,16 @@ export function McpDynamicArgs({
}
updateParameter(paramName, processedValue)
}}
isPreview={isPreview}
placeholder={
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description ||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
}
disabled={disabled}
isPassword={isPassword}
blockId={blockId}
accessiblePrefixes={accessiblePrefixes}
/>
)
}
@@ -331,40 +578,26 @@ export function McpDynamicArgs({
tabIndex={-1}
readOnly
/>
<div>
<div className='space-y-4'>
{toolSchema.properties &&
Object.entries(toolSchema.properties).map(([paramName, paramSchema], index, entries) => {
Object.entries(toolSchema.properties).map(([paramName, paramSchema]) => {
const inputType = getInputType(paramSchema as any)
const showLabel = inputType !== 'switch'
const showDivider = index < entries.length - 1
return (
<div key={paramName} className='subblock-row'>
<div className='subblock-content flex flex-col gap-[10px]'>
{showLabel && (
<Label
className={cn(
'font-medium text-sm',
toolSchema.required?.includes(paramName) &&
'after:ml-1 after:text-red-500 after:content-["*"]'
)}
>
{formatParameterLabel(paramName)}
</Label>
)}
{renderParameterInput(paramName, paramSchema as any)}
</div>
{showDivider && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
<div key={paramName} className='space-y-2'>
{showLabel && (
<Label
className={cn(
'font-medium text-sm',
toolSchema.required?.includes(paramName) &&
'after:ml-1 after:text-red-500 after:content-["*"]'
)}
>
{formatParameterLabel(paramName)}
</Label>
)}
{renderParameterInput(paramName, paramSchema as any)}
</div>
)
})}

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash'
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
@@ -226,18 +225,14 @@ export function MessagesInput({
[wandHook]
)
const localMessagesRef = useRef(localMessages)
localMessagesRef.current = localMessages
/**
* Initialize local state from stored or preview value
*/
useEffect(() => {
if (isPreview && previewValue && Array.isArray(previewValue)) {
if (!isEqual(localMessagesRef.current, previewValue)) {
setLocalMessages(previewValue)
}
setLocalMessages(previewValue)
} else if (messages && Array.isArray(messages) && messages.length > 0) {
if (!isEqual(localMessagesRef.current, messages)) {
setLocalMessages(messages)
}
setLocalMessages(messages)
}
}, [isPreview, previewValue, messages])

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { Check, Copy, Wand2 } from 'lucide-react'
import { useReactFlow } from 'reactflow'
import { Input } from '@/components/emcn'
@@ -62,7 +62,7 @@ interface ShortInputProps {
* - Copy to clipboard functionality
* - Integrates with ReactFlow for zoom control
*/
export const ShortInput = memo(function ShortInput({
export function ShortInput({
blockId,
subBlockId,
placeholder,
@@ -445,4 +445,4 @@ export const ShortInput = memo(function ShortInput({
</div>
</>
)
})
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'
import { useRef } from 'react'
import { Plus } from 'lucide-react'
import { Trash } from '@/components/emcn/icons/trash'
import 'prismjs/components/prism-json'
@@ -81,8 +81,6 @@ const createDefaultField = (): Field => ({
*/
const validateFieldName = (name: string): string => name.replace(/[\x00-\x1F"\\]/g, '').trim()
const jsonHighlight = (code: string): string => highlight(code, languages.json, 'json')
export function FieldFormat({
blockId,
subBlockId,
@@ -140,50 +138,17 @@ export function FieldFormat({
setStoreValue(fields.filter((field) => field.id !== id))
}
const storeValueRef = useRef(storeValue)
storeValueRef.current = storeValue
/**
* Updates a specific field property
*/
const updateField = (id: string, field: keyof Field, value: any) => {
if (isReadOnly) return
const isReadOnlyRef = useRef(isReadOnly)
isReadOnlyRef.current = isReadOnly
const updatedValue =
field === 'name' && typeof value === 'string' ? validateFieldName(value) : value
const setStoreValueRef = useRef(setStoreValue)
setStoreValueRef.current = setStoreValue
const updateField = useCallback(
(id: string, fieldKey: keyof Field, fieldValue: Field[keyof Field]) => {
if (isReadOnlyRef.current) return
const updatedValue =
fieldKey === 'name' && typeof fieldValue === 'string'
? validateFieldName(fieldValue)
: fieldValue
const currentStoreValue = storeValueRef.current
const currentFields: Field[] =
Array.isArray(currentStoreValue) && currentStoreValue.length > 0
? currentStoreValue
: [createDefaultField()]
setStoreValueRef.current(
currentFields.map((f) => (f.id === id ? { ...f, [fieldKey]: updatedValue } : f))
)
},
[]
)
const editorValueChangeHandlersRef = useRef<Record<string, (newValue: string) => void>>({})
const getEditorValueChangeHandler = useCallback(
(fieldId: string): ((newValue: string) => void) => {
if (!editorValueChangeHandlersRef.current[fieldId]) {
editorValueChangeHandlersRef.current[fieldId] = (newValue: string) => {
updateField(fieldId, 'value', newValue)
}
}
return editorValueChangeHandlersRef.current[fieldId]
},
[updateField]
)
setStoreValue(fields.map((f) => (f.id === id ? { ...f, [field]: updatedValue } : f)))
}
/**
* Toggles the collapsed state of a field
@@ -257,14 +222,15 @@ export function FieldFormat({
placeholder={placeholder}
disabled={isReadOnly}
autoComplete='off'
className={cn('allow-scroll w-full overflow-x-auto overflow-y-hidden', inputClassName)}
className={cn('allow-scroll w-full overflow-auto', inputClassName)}
style={{ overflowX: 'auto' }}
/>
<div
ref={(el) => {
if (el) nameOverlayRefs.current[field.id] = el
}}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
style={{ scrollbarWidth: 'none' }}
style={{ overflowX: 'auto' }}
>
<div
className='w-full whitespace-pre'
@@ -393,8 +359,12 @@ export function FieldFormat({
</Code.Placeholder>
<Editor
value={fieldValue}
onValueChange={getEditorValueChangeHandler(field.id)}
highlight={jsonHighlight}
onValueChange={(newValue) => {
if (!isReadOnly) {
updateField(field.id, 'value', newValue)
}
}}
highlight={(code) => highlight(code, languages.json, 'json')}
disabled={isReadOnly}
{...getCodeEditorProps({ disabled: isReadOnly })}
/>
@@ -428,8 +398,12 @@ export function FieldFormat({
</Code.Placeholder>
<Editor
value={fieldValue}
onValueChange={getEditorValueChangeHandler(field.id)}
highlight={jsonHighlight}
onValueChange={(newValue) => {
if (!isReadOnly) {
updateField(field.id, 'value', newValue)
}
}}
highlight={(code) => highlight(code, languages.json, 'json')}
disabled={isReadOnly}
{...getCodeEditorProps({ disabled: isReadOnly })}
/>
@@ -465,8 +439,12 @@ export function FieldFormat({
</Code.Placeholder>
<Editor
value={fieldValue}
onValueChange={getEditorValueChangeHandler(field.id)}
highlight={jsonHighlight}
onValueChange={(newValue) => {
if (!isReadOnly) {
updateField(field.id, 'value', newValue)
}
}}
highlight={(code) => highlight(code, languages.json, 'json')}
disabled={isReadOnly}
{...getCodeEditorProps({ disabled: isReadOnly })}
/>
@@ -498,14 +476,15 @@ export function FieldFormat({
placeholder={valuePlaceholder}
disabled={isReadOnly}
autoComplete='off'
className={cn('allow-scroll w-full overflow-x-auto overflow-y-hidden', inputClassName)}
className={cn('allow-scroll w-full overflow-auto', inputClassName)}
style={{ overflowX: 'auto' }}
/>
<div
ref={(el) => {
if (el) overlayRefs.current[field.id] = el
}}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
style={{ scrollbarWidth: 'none' }}
style={{ overflowX: 'auto' }}
>
<div
className='w-full whitespace-pre'

View File

@@ -1,7 +1,6 @@
import { useEffect, useMemo } from 'react'
import { usePopoverContext } from '@/components/emcn'
import { useNestedNavigation } from '../tag-dropdown'
import type { BlockTagGroup, NestedBlockTagGroup, NestedTag } from '../types'
import type { BlockTagGroup, NestedBlockTagGroup } from '../types'
/**
* Keyboard navigation handler component that uses popover context
@@ -16,90 +15,6 @@ interface KeyboardNavigationHandlerProps {
handleTagSelect: (tag: string, group?: BlockTagGroup) => void
}
/**
* Recursively finds a folder in nested tags by its ID
*/
const findFolderInNested = (
nestedTags: NestedTag[],
blockId: string,
targetFolderId: string
): NestedTag | null => {
for (const nestedTag of nestedTags) {
const folderId = `${blockId}-${nestedTag.key}`
if (folderId === targetFolderId) {
return nestedTag
}
if (nestedTag.nestedChildren) {
const found = findFolderInNested(nestedTag.nestedChildren, blockId, targetFolderId)
if (found) return found
}
}
return null
}
/**
* Recursively finds folder info for a tag that can be expanded.
* Returns both the folder metadata and the NestedTag object for navigation.
*/
const findFolderInfoForTag = (
nestedTags: NestedTag[],
targetTag: string,
group: NestedBlockTagGroup
): {
id: string
title: string
parentTag: string
group: NestedBlockTagGroup
nestedTag: NestedTag
} | null => {
for (const nestedTag of nestedTags) {
if (
nestedTag.parentTag === targetTag &&
(nestedTag.children?.length || nestedTag.nestedChildren?.length)
) {
return {
id: `${group.blockId}-${nestedTag.key}`,
title: nestedTag.display,
parentTag: nestedTag.parentTag,
group,
nestedTag,
}
}
if (nestedTag.nestedChildren) {
const found = findFolderInfoForTag(nestedTag.nestedChildren, targetTag, group)
if (found) return found
}
}
return null
}
/**
* Recursively checks if a tag is a child of any folder.
* This includes both leaf children and nested folder parent tags.
*/
const isChildOfAnyFolder = (nestedTags: NestedTag[], tag: string): boolean => {
for (const nestedTag of nestedTags) {
if (nestedTag.children) {
for (const child of nestedTag.children) {
if (child.fullTag === tag) {
return true
}
}
}
if (nestedTag.nestedChildren) {
for (const nestedChild of nestedTag.nestedChildren) {
if (nestedChild.parentTag === tag) {
return true
}
}
if (isChildOfAnyFolder(nestedTag.nestedChildren, tag)) {
return true
}
}
}
return false
}
export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps> = ({
visible,
selectedIndex,
@@ -108,66 +23,55 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
nestedBlockTagGroups,
handleTagSelect,
}) => {
const { openFolder, closeFolder, isInFolder, currentFolder, setKeyboardNav } = usePopoverContext()
const nestedNav = useNestedNavigation()
const { openFolder, closeFolder, isInFolder, currentFolder } = usePopoverContext()
const visibleIndices = useMemo(() => {
const indices: number[] = []
const nestedPath = nestedNav?.nestedPath ?? []
if (isInFolder && currentFolder) {
let currentNestedTag: NestedTag | null = null
if (nestedPath.length > 0) {
currentNestedTag = nestedPath[nestedPath.length - 1]
} else {
for (const group of nestedBlockTagGroups) {
const folder = findFolderInNested(group.nestedTags, group.blockId, currentFolder)
if (folder) {
currentNestedTag = folder
break
}
}
}
if (currentNestedTag) {
if (currentNestedTag.parentTag) {
const parentIdx = flatTagList.findIndex(
(item) => item.tag === currentNestedTag!.parentTag
)
if (parentIdx >= 0) {
indices.push(parentIdx)
}
}
if (currentNestedTag.children) {
for (const child of currentNestedTag.children) {
const idx = flatTagList.findIndex((item) => item.tag === child.fullTag)
if (idx >= 0) {
indices.push(idx)
for (const group of nestedBlockTagGroups) {
for (const nestedTag of group.nestedTags) {
const folderId = `${group.blockId}-${nestedTag.key}`
if (folderId === currentFolder && nestedTag.children) {
// First, add the parent tag itself (so it's navigable as the first item)
if (nestedTag.parentTag) {
const parentIdx = flatTagList.findIndex((item) => item.tag === nestedTag.parentTag)
if (parentIdx >= 0) {
indices.push(parentIdx)
}
}
}
}
if (currentNestedTag.nestedChildren) {
for (const nestedChild of currentNestedTag.nestedChildren) {
if (nestedChild.parentTag) {
const idx = flatTagList.findIndex((item) => item.tag === nestedChild.parentTag)
// Then add all children
for (const child of nestedTag.children) {
const idx = flatTagList.findIndex((item) => item.tag === child.fullTag)
if (idx >= 0) {
indices.push(idx)
}
}
break
}
}
}
} else {
// We're at root level, show all non-child items
// (variables and parent tags, but not their children)
for (let i = 0; i < flatTagList.length; i++) {
const tag = flatTagList[i].tag
// Check if this is a child of a parent folder
let isChild = false
for (const group of nestedBlockTagGroups) {
if (isChildOfAnyFolder(group.nestedTags, tag)) {
isChild = true
break
for (const nestedTag of group.nestedTags) {
if (nestedTag.children) {
for (const child of nestedTag.children) {
if (child.fullTag === tag) {
isChild = true
break
}
}
}
if (isChild) break
}
if (isChild) break
}
if (!isChild) {
@@ -177,16 +81,16 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
}
return indices
}, [isInFolder, currentFolder, flatTagList, nestedBlockTagGroups, nestedNav])
const nestedPathLength = nestedNav?.nestedPath.length ?? 0
}, [isInFolder, currentFolder, flatTagList, nestedBlockTagGroups])
// Auto-select first visible item when entering/exiting folders
useEffect(() => {
if (!visible || visibleIndices.length === 0) return
setSelectedIndex(visibleIndices[0])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, isInFolder, currentFolder, nestedPathLength])
if (!visibleIndices.includes(selectedIndex)) {
setSelectedIndex(visibleIndices[0])
}
}, [visible, isInFolder, currentFolder, visibleIndices, selectedIndex, setSelectedIndex])
useEffect(() => {
if (!visible || !flatTagList.length) return
@@ -213,98 +117,89 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
id: string
title: string
parentTag: string
group: NestedBlockTagGroup
nestedTag: NestedTag
group: BlockTagGroup
} | null = null
if (selected) {
for (const group of nestedBlockTagGroups) {
const folderInfo = findFolderInfoForTag(group.nestedTags, selected.tag, group)
if (folderInfo) {
currentFolderInfo = folderInfo
break
for (const nestedTag of group.nestedTags) {
if (
nestedTag.parentTag === selected.tag &&
nestedTag.children &&
nestedTag.children.length > 0
) {
currentFolderInfo = {
id: `${selected.group?.blockId}-${nestedTag.key}`,
title: nestedTag.display,
parentTag: nestedTag.parentTag,
group,
}
break
}
}
if (currentFolderInfo) break
}
}
const scrollIntoView = () => {
setTimeout(() => {
const selectedItem = document.querySelector<HTMLElement>(
'[data-radix-popper-content-wrapper] [aria-selected="true"]'
)
if (selectedItem) {
selectedItem.scrollIntoView({ behavior: 'auto', block: 'nearest' })
}
}, 0)
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
e.stopPropagation()
setKeyboardNav(true)
if (visibleIndices.length > 0) {
const currentVisibleIndex = visibleIndices.indexOf(selectedIndex)
let newIndex: number
if (currentVisibleIndex === -1) {
newIndex = visibleIndices[0]
setSelectedIndex(visibleIndices[0])
} else if (currentVisibleIndex < visibleIndices.length - 1) {
newIndex = visibleIndices[currentVisibleIndex + 1]
} else {
newIndex = visibleIndices[0]
setSelectedIndex(visibleIndices[currentVisibleIndex + 1])
}
setSelectedIndex(newIndex)
scrollIntoView()
}
break
case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
setKeyboardNav(true)
if (visibleIndices.length > 0) {
const currentVisibleIndex = visibleIndices.indexOf(selectedIndex)
let newIndex: number
if (currentVisibleIndex === -1) {
newIndex = visibleIndices[visibleIndices.length - 1]
setSelectedIndex(visibleIndices[0])
} else if (currentVisibleIndex > 0) {
newIndex = visibleIndices[currentVisibleIndex - 1]
} else {
newIndex = visibleIndices[visibleIndices.length - 1]
setSelectedIndex(visibleIndices[currentVisibleIndex - 1])
}
setSelectedIndex(newIndex)
scrollIntoView()
}
break
case 'Enter':
e.preventDefault()
e.stopPropagation()
if (selected && selectedIndex >= 0 && selectedIndex < flatTagList.length) {
handleTagSelect(selected.tag, selected.group)
}
break
case 'ArrowRight':
if (currentFolderInfo) {
e.preventDefault()
e.stopPropagation()
if (isInFolder && nestedNav) {
nestedNav.navigateIn(currentFolderInfo.nestedTag, currentFolderInfo.group)
} else {
if (currentFolderInfo && !isInFolder) {
// It's a folder, open it
openFolderWithSelection(
currentFolderInfo.id,
currentFolderInfo.title,
currentFolderInfo.parentTag,
currentFolderInfo.group
)
} else {
// Not a folder, select it
handleTagSelect(selected.tag, selected.group)
}
}
break
case 'ArrowRight':
if (currentFolderInfo && !isInFolder) {
e.preventDefault()
e.stopPropagation()
openFolderWithSelection(
currentFolderInfo.id,
currentFolderInfo.title,
currentFolderInfo.parentTag,
currentFolderInfo.group
)
}
break
case 'ArrowLeft':
if (isInFolder) {
e.preventDefault()
e.stopPropagation()
if (nestedNav?.navigateBack()) {
return
}
closeFolder()
let firstRootIndex = 0
for (let i = 0; i < flatTagList.length; i++) {
@@ -344,8 +239,6 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
isInFolder,
setSelectedIndex,
handleTagSelect,
nestedNav,
setKeyboardNav,
])
return null

View File

@@ -10,27 +10,14 @@ export interface BlockTagGroup {
}
/**
* Child tag within a nested structure
*/
export interface NestedTagChild {
key: string
display: string
fullTag: string
}
/**
* Nested tag structure for hierarchical display.
* Supports recursive nesting for deeply nested object structures.
* Nested tag structure for hierarchical display
*/
export interface NestedTag {
key: string
display: string
fullTag?: string
parentTag?: string
/** Leaf children (no further nesting) */
children?: NestedTagChild[]
/** Recursively nested folders */
nestedChildren?: NestedTag[]
parentTag?: string // Tag for the parent object when it has children
children?: Array<{ key: string; display: string; fullTag: string }>
}
/**

View File

@@ -1,5 +1,5 @@
import type React from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Loader2, WrenchIcon, XIcon } from 'lucide-react'
import { useParams } from 'next/navigation'
@@ -35,7 +35,6 @@ import {
Code,
FileSelectorInput,
FileUpload,
FolderSelectorInput,
LongInput,
ProjectSelectorInput,
SheetSelectorInput,
@@ -46,9 +45,7 @@ import {
TimeInput,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
import { DocumentSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-selector/document-selector'
import { DocumentTagEntry } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry'
import { KnowledgeBaseSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector'
import { KnowledgeTagFilters } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters'
import {
type CustomTool,
CustomToolModal,
@@ -78,13 +75,6 @@ import {
isPasswordParameter,
type ToolParameterConfig,
} from '@/tools/params'
import {
buildCanonicalIndex,
buildPreviewContextValues,
type CanonicalIndex,
evaluateSubBlockCondition,
type SubBlockCondition,
} from '@/tools/params-resolver'
const logger = createLogger('ToolInput')
@@ -314,42 +304,6 @@ function SheetSelectorSyncWrapper({
)
}
function FolderSelectorSyncWrapper({
blockId,
paramId,
value,
onChange,
uiComponent,
disabled,
previewContextValues,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
uiComponent: any
disabled: boolean
previewContextValues?: Record<string, any>
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<FolderSelectorInput
blockId={blockId}
subBlock={{
id: paramId,
type: 'folder-selector' as const,
title: paramId,
serviceId: uiComponent.serviceId,
requiredScopes: uiComponent.requiredScopes || [],
placeholder: uiComponent.placeholder,
dependsOn: uiComponent.dependsOn,
}}
disabled={disabled}
/>
</GenericSyncWrapper>
)
}
function KnowledgeBaseSelectorSyncWrapper({
blockId,
paramId,
@@ -388,7 +342,6 @@ function DocumentSelectorSyncWrapper({
onChange,
uiComponent,
disabled,
previewContextValues,
}: {
blockId: string
paramId: string
@@ -396,7 +349,6 @@ function DocumentSelectorSyncWrapper({
onChange: (value: string) => void
uiComponent: any
disabled: boolean
previewContextValues?: Record<string, any>
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
@@ -409,67 +361,6 @@ function DocumentSelectorSyncWrapper({
dependsOn: ['knowledgeBaseId'],
}}
disabled={disabled}
previewContextValues={previewContextValues}
/>
</GenericSyncWrapper>
)
}
function DocumentTagEntrySyncWrapper({
blockId,
paramId,
value,
onChange,
disabled,
previewContextValues,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
disabled: boolean
previewContextValues?: Record<string, any>
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<DocumentTagEntry
blockId={blockId}
subBlock={{
id: paramId,
type: 'document-tag-entry',
}}
disabled={disabled}
previewContextValues={previewContextValues}
/>
</GenericSyncWrapper>
)
}
function KnowledgeTagFiltersSyncWrapper({
blockId,
paramId,
value,
onChange,
disabled,
previewContextValues,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
disabled: boolean
previewContextValues?: Record<string, any>
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<KnowledgeTagFilters
blockId={blockId}
subBlock={{
id: paramId,
type: 'knowledge-tag-filters',
}}
disabled={disabled}
previewContextValues={previewContextValues}
/>
</GenericSyncWrapper>
)
@@ -606,15 +497,11 @@ function CheckboxListSyncWrapper({
}
function ComboboxSyncWrapper({
blockId,
paramId,
value,
onChange,
uiComponent,
disabled,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
uiComponent: any
@@ -625,15 +512,13 @@ function ComboboxSyncWrapper({
)
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<Combobox
options={options}
value={value}
onChange={onChange}
placeholder={uiComponent.placeholder || 'Select option'}
disabled={disabled}
/>
</GenericSyncWrapper>
<Combobox
options={options}
value={value}
onChange={onChange}
placeholder={uiComponent.placeholder || 'Select option'}
disabled={disabled}
/>
)
}
@@ -712,8 +597,6 @@ function SlackSelectorSyncWrapper({
}
function WorkflowSelectorSyncWrapper({
blockId,
paramId,
value,
onChange,
uiComponent,
@@ -721,8 +604,6 @@ function WorkflowSelectorSyncWrapper({
workspaceId,
currentWorkflowId,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
uiComponent: any
@@ -742,17 +623,15 @@ function WorkflowSelectorSyncWrapper({
}))
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<Combobox
options={options}
value={value}
onChange={onChange}
placeholder={uiComponent.placeholder || 'Select workflow'}
disabled={disabled || isLoading}
searchable
searchPlaceholder='Search workflows...'
/>
</GenericSyncWrapper>
<Combobox
options={options}
value={value}
onChange={onChange}
placeholder={uiComponent.placeholder || 'Select workflow'}
disabled={disabled || isLoading}
searchable
searchPlaceholder='Search workflows...'
/>
)
}
@@ -998,7 +877,7 @@ function createToolIcon(bgColor: string, IconComponent: any) {
* - Allows drag-and-drop reordering of selected tools
* - Supports tool usage control (auto/force/none) for compatible LLM providers
*/
export const ToolInput = memo(function ToolInput({
export function ToolInput({
blockId,
subBlockId,
isPreview = false,
@@ -1913,13 +1792,57 @@ export const ToolInput = memo(function ToolInput({
return toolParams?.toolConfig?.oauth
}
/**
* Evaluates parameter conditions to determine if a parameter should be visible.
*
* @remarks
* Supports field value matching with arrays, negation via `not`, and
* compound conditions via `and`. Used for conditional parameter visibility.
*
* @param param - The parameter configuration with optional condition
* @param tool - The current tool instance with its parameter values
* @returns `true` if the parameter should be shown based on its condition
*/
const evaluateParameterCondition = (param: any, tool: StoredTool): boolean => {
if (!('uiComponent' in param) || !param.uiComponent?.condition) return true
const currentValues: Record<string, any> = { operation: tool.operation, ...tool.params }
return evaluateSubBlockCondition(
param.uiComponent.condition as SubBlockCondition,
currentValues
)
const condition = param.uiComponent.condition
const currentValues: Record<string, any> = {
operation: tool.operation,
...tool.params,
}
const fieldValue = currentValues[condition.field]
let result = false
if (Array.isArray(condition.value)) {
result = condition.value.includes(fieldValue)
} else {
result = fieldValue === condition.value
}
if (condition.not) {
result = !result
}
if (condition.and) {
const andFieldValue = currentValues[condition.and.field]
let andResult = false
if (Array.isArray(condition.and.value)) {
andResult = condition.and.value.includes(andFieldValue)
} else {
andResult = andFieldValue === condition.and.value
}
if (condition.and.not) {
andResult = !andResult
}
result = result && andResult
}
return result
}
/**
@@ -2038,7 +1961,7 @@ export const ToolInput = memo(function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams}
previewContextValues={currentToolParams as any}
selectorType='channel-selector'
/>
)
@@ -2052,7 +1975,7 @@ export const ToolInput = memo(function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams}
previewContextValues={currentToolParams as any}
selectorType='user-selector'
/>
)
@@ -2072,7 +1995,7 @@ export const ToolInput = memo(function ToolInput({
}}
onProjectSelect={onChange}
disabled={disabled}
previewContextValues={currentToolParams}
previewContextValues={currentToolParams as any}
/>
)
@@ -2097,7 +2020,7 @@ export const ToolInput = memo(function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams}
previewContextValues={currentToolParams as any}
/>
)
@@ -2110,20 +2033,7 @@ export const ToolInput = memo(function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams}
/>
)
case 'folder-selector':
return (
<FolderSelectorSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams}
previewContextValues={currentToolParams as any}
/>
)
@@ -2142,8 +2052,6 @@ export const ToolInput = memo(function ToolInput({
case 'combobox':
return (
<ComboboxSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
uiComponent={uiComponent}
@@ -2202,8 +2110,6 @@ export const ToolInput = memo(function ToolInput({
case 'workflow-selector':
return (
<WorkflowSelectorSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
uiComponent={uiComponent}
@@ -2261,31 +2167,6 @@ export const ToolInput = memo(function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams}
/>
)
case 'document-tag-entry':
return (
<DocumentTagEntrySyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
disabled={disabled}
previewContextValues={currentToolParams}
/>
)
case 'knowledge-tag-filters':
return (
<KnowledgeTagFiltersSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
disabled={disabled}
previewContextValues={currentToolParams}
/>
)
@@ -2344,27 +2225,9 @@ export const ToolInput = memo(function ToolInput({
// Get tool parameters using the new utility with block type for UI components
const toolParams =
!isCustomTool && !isMcpTool && currentToolId
? getToolParametersConfig(currentToolId, tool.type, {
operation: tool.operation,
...tool.params,
})
? getToolParametersConfig(currentToolId, tool.type)
: null
// Build canonical index for proper dependency resolution
const toolCanonicalIndex: CanonicalIndex | null = toolBlock?.subBlocks
? buildCanonicalIndex(toolBlock.subBlocks)
: null
// Build preview context with canonical resolution
const toolContextValues = toolCanonicalIndex
? buildPreviewContextValues(tool.params || {}, {
blockType: tool.type,
subBlocks: toolBlock!.subBlocks,
canonicalIndex: toolCanonicalIndex,
values: { operation: tool.operation, ...tool.params },
})
: tool.params || {}
// For custom tools, resolve from reference (new format) or use inline (legacy)
const resolvedCustomTool = isCustomTool
? resolveCustomToolFromReference(tool, customTools)
@@ -2727,7 +2590,7 @@ export const ToolInput = memo(function ToolInput({
{param.required && param.visibility === 'user-only' && (
<span className='ml-1'>*</span>
)}
{param.visibility === 'user-or-llm' && (
{(!param.required || param.visibility !== 'user-only') && (
<span className='ml-[6px] text-[12px] text-[var(--text-tertiary)]'>
(optional)
</span>
@@ -2740,7 +2603,7 @@ export const ToolInput = memo(function ToolInput({
tool.params?.[param.id] || '',
(value) => handleParamChange(toolIndex, param.id, value),
toolIndex,
toolContextValues as Record<string, string>
tool.params || {}
)
) : (
<ShortInput
@@ -2819,4 +2682,4 @@ export const ToolInput = memo(function ToolInput({
/>
</div>
)
})
}

View File

@@ -1,7 +1,6 @@
'use client'
import { useCallback, useMemo } from 'react'
import { isEqual } from 'lodash'
import { useMemo } from 'react'
import {
buildCanonicalIndex,
isNonEmptyValue,
@@ -98,60 +97,47 @@ export function useDependsOnGate(
return rawValue
}
const dependencySelector = useCallback(
(state: ReturnType<typeof useSubBlockStore.getState>) => {
if (allDependsOnFields.length === 0) return {} as Record<string, unknown>
// Get values for all dependency fields (both all and any)
const dependencyValuesMap = useSubBlockStore((state) => {
if (allDependsOnFields.length === 0) return {} as Record<string, unknown>
// If previewContextValues are provided (e.g., tool parameters), use those first
if (previewContextValues) {
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
const resolvedValue = resolveDependencyValue(
key,
previewContextValues,
canonicalIndex,
canonicalModeOverrides
)
map[key] = normalizeDependencyValue(resolvedValue)
}
return map
}
if (!activeWorkflowId) {
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
map[key] = null
}
return map
}
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = (workflowValues as any)[blockId] || {}
// If previewContextValues are provided (e.g., tool parameters), use those first
if (previewContextValues) {
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
const resolvedValue = resolveDependencyValue(
key,
blockValues,
previewContextValues,
canonicalIndex,
canonicalModeOverrides
)
map[key] = normalizeDependencyValue(resolvedValue)
}
return map
},
[
allDependsOnFields,
previewContextValues,
activeWorkflowId,
blockId,
canonicalIndex,
canonicalModeOverrides,
]
)
}
// Get values for all dependency fields (both all and any)
// Use isEqual to prevent re-renders when dependency values haven't actually changed
const dependencyValuesMap = useSubBlockStore(dependencySelector, isEqual)
if (!activeWorkflowId) {
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
map[key] = null
}
return map
}
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = (workflowValues as any)[blockId] || {}
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
const resolvedValue = resolveDependencyValue(
key,
blockValues,
canonicalIndex,
canonicalModeOverrides
)
map[key] = normalizeDependencyValue(resolvedValue)
}
return map
})
const depsSatisfied = useMemo(() => {
// Check all fields (AND logic) - all must be satisfied

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