Compare commits

..

5 Commits

Author SHA1 Message Date
Siddharth Ganesan
66d19c00db Stuff 2026-01-13 20:54:04 -08:00
Siddharth Ganesan
a45426bb6b Fix drag 2026-01-13 19:38:50 -08:00
Siddharth Ganesan
a3007d8980 Grouping 2026-01-13 19:08:28 -08:00
Siddharth Ganesan
8ec067d280 Ring light 2026-01-13 18:53:19 -08:00
Siddharth Ganesan
f04cd7c355 Groups v0 2026-01-13 18:23:50 -08:00
144 changed files with 5270 additions and 14519 deletions

View File

@@ -552,53 +552,6 @@ All fields automatically have:
- `mode: 'trigger'` - Only shown in trigger mode
- `condition: { field: 'selectedTriggerId', value: triggerId }` - Only shown when this trigger is selected
## Trigger Outputs & Webhook Input Formatting
### Important: Two Sources of Truth
There are two related but separate concerns:
1. **Trigger `outputs`** - Schema/contract defining what fields SHOULD be available. Used by UI for tag dropdown.
2. **`formatWebhookInput`** - Implementation that transforms raw webhook payload into actual data. Located in `apps/sim/lib/webhooks/utils.server.ts`.
**These MUST be aligned.** The fields returned by `formatWebhookInput` should match what's defined in trigger `outputs`. If they differ:
- Tag dropdown shows fields that don't exist (broken variable resolution)
- Or actual data has fields not shown in dropdown (users can't discover them)
### When to Add a formatWebhookInput Handler
- **Simple providers**: If the raw webhook payload structure already matches your outputs, you don't need a handler. The generic fallback returns `body` directly.
- **Complex providers**: If you need to transform, flatten, extract nested data, compute fields, or handle conditional logic, add a handler.
### Adding a Handler
In `apps/sim/lib/webhooks/utils.server.ts`, add a handler block:
```typescript
if (foundWebhook.provider === '{service}') {
// Transform raw webhook body to match trigger outputs
return {
eventType: body.type,
resourceId: body.data?.id || '',
timestamp: body.created_at,
resource: body.data,
}
}
```
**Key rules:**
- Return fields that match your trigger `outputs` definition exactly
- No wrapper objects like `webhook: { data: ... }` or `{service}: { ... }`
- No duplication (don't spread body AND add individual fields)
- Use `null` for missing optional data, not empty objects with empty strings
### Verify Alignment
Run the alignment checker:
```bash
bunx scripts/check-trigger-alignment.ts {service}
```
## Trigger Outputs
Trigger outputs use the same schema as block outputs (NOT tool outputs).
@@ -696,11 +649,6 @@ export const {service}WebhookTrigger: TriggerConfig = {
- [ ] Added `delete{Service}Webhook` function to `provider-subscriptions.ts`
- [ ] Added provider to `cleanupExternalWebhook` function
### Webhook Input Formatting
- [ ] Added handler in `apps/sim/lib/webhooks/utils.server.ts` (if custom formatting needed)
- [ ] Handler returns fields matching trigger `outputs` exactly
- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify alignment
### Testing
- [ ] Run `bun run type-check` to verify no TypeScript errors
- [ ] Restart dev server to pick up new triggers

View File

@@ -1,10 +1,11 @@
name: 'Auto-translate Documentation'
on:
schedule:
# Run every Sunday at midnight UTC
- cron: '0 0 * * 0'
workflow_dispatch: # Allow manual triggers
push:
branches: [ staging ]
paths:
- 'apps/docs/content/docs/en/**'
- 'apps/docs/i18n.json'
permissions:
contents: write
@@ -19,7 +20,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: staging
token: ${{ secrets.GH_PAT }}
fetch-depth: 0
@@ -68,11 +68,12 @@ jobs:
title: "feat(i18n): update translations"
body: |
## Summary
Automated weekly translation updates for documentation.
This PR was automatically created by the scheduled weekly i18n workflow, updating translations for all supported languages using Lingo.dev AI translation engine.
**Triggered**: Weekly scheduled run
Automated translation updates triggered by changes to documentation.
This PR was automatically created after content changes were made, updating translations for all supported languages using Lingo.dev AI translation engine.
**Original trigger**: ${{ github.event.head_commit.message }}
**Commit**: ${{ github.sha }}
**Workflow**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
## Type of Change
@@ -106,7 +107,7 @@ jobs:
## Screenshots/Videos
<!-- Translation changes are text-based - no visual changes expected -->
<!-- Reviewers should check the documentation site renders correctly for all languages -->
branch: auto-translate/weekly-${{ github.run_id }}
branch: auto-translate/staging-merge-${{ github.run_id }}
base: staging
labels: |
i18n
@@ -144,8 +145,6 @@ jobs:
bun install --frozen-lockfile
- name: Build documentation to verify translations
env:
DATABASE_URL: postgresql://dummy:dummy@localhost:5432/dummy
run: |
cd apps/docs
bun run build
@@ -154,7 +153,7 @@ jobs:
run: |
cd apps/docs
echo "## Translation Status Report" >> $GITHUB_STEP_SUMMARY
echo "**Weekly scheduled translation run**" >> $GITHUB_STEP_SUMMARY
echo "**Triggered by merge to staging branch**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
en_count=$(find content/docs/en -name "*.mdx" | wc -l)

View File

@@ -1855,25 +1855,17 @@ export function LinearIcon(props: React.SVGProps<SVGSVGElement>) {
export function LemlistIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 181' fill='none'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M32.0524 0.919922H147.948C165.65 0.919922 180 15.2703 180 32.9723V148.867C180 166.57 165.65 180.92 147.948 180.92H32.0524C14.3504 180.92 0 166.57 0 148.867V32.9723C0 15.2703 14.3504 0.919922 32.0524 0.919922ZM119.562 82.8879H85.0826C82.4732 82.8879 80.3579 85.0032 80.3579 87.6126V94.2348C80.3579 96.8442 82.4732 98.9595 85.0826 98.9595H119.562C122.171 98.9595 124.286 96.8442 124.286 94.2348V87.6126C124.286 85.0032 122.171 82.8879 119.562 82.8879ZM85.0826 49.1346H127.061C129.67 49.1346 131.785 51.2499 131.785 53.8593V60.4815C131.785 63.0909 129.67 65.2062 127.061 65.2062H85.0826C82.4732 65.2062 80.3579 63.0909 80.3579 60.4815V53.8593C80.3579 51.2499 82.4732 49.1346 85.0826 49.1346ZM131.785 127.981V121.358C131.785 118.75 129.669 116.634 127.061 116.634H76.5706C69.7821 116.634 64.2863 111.138 64.2863 104.349V53.8593C64.2863 51.2513 62.1697 49.1346 59.5616 49.1346H52.9395C50.3314 49.1346 48.2147 51.2513 48.2147 53.8593V114.199C48.8497 124.133 56.7873 132.07 66.7205 132.705H127.061C129.669 132.705 131.785 130.589 131.785 127.981Z'
fill='#316BFF'
/>
<path
d='M85.0826 49.1346H127.061C129.67 49.1346 131.785 51.2499 131.785 53.8593V60.4815C131.785 63.0909 129.67 65.2062 127.061 65.2062H85.0826C82.4732 65.2062 80.3579 63.0909 80.3579 60.4815V53.8593C80.3579 51.2499 82.4732 49.1346 85.0826 49.1346Z'
fill='white'
/>
<path
d='M85.0826 82.8879H119.562C122.171 82.8879 124.286 85.0032 124.286 87.6126V94.2348C124.286 96.8442 122.171 98.9595 119.562 98.9595H85.0826C82.4732 98.9595 80.3579 96.8442 80.3579 94.2348V87.6126C80.3579 85.0032 82.4732 82.8879 85.0826 82.8879Z'
fill='white'
/>
<path
d='M131.785 121.358V127.981C131.785 130.589 129.669 132.705 127.061 132.705H66.7205C56.7873 132.07 48.8497 124.133 48.2147 114.199V53.8593C48.2147 51.2513 50.3314 49.1346 52.9395 49.1346H59.5616C62.1697 49.1346 64.2863 51.2513 64.2863 53.8593V104.349C64.2863 111.138 69.7821 116.634 76.5706 116.634H127.061C129.669 116.634 131.785 118.75 131.785 121.358Z'
fill='white'
/>
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
width='24'
height='24'
fill='none'
>
<rect width='24' height='24' rx='4' fill='#316BFF' />
<path d='M7 6h2v9h5v2H7V6Z' fill='white' />
<circle cx='17' cy='8' r='2' fill='white' />
</svg>
)
}
@@ -1897,19 +1889,6 @@ export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function TinybirdIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'>
<rect x='0' y='0' width='24' height='24' fill='#2EF598' rx='6' />
<g transform='translate(2, 2) scale(0.833)'>
<path d='M25 2.64 17.195.5 14.45 6.635z' fill='#1E7F63' />
<path d='M17.535 17.77 10.39 15.215 6.195 25.5z' fill='#1E7F63' />
<path d='M0 11.495 17.535 17.77 20.41 4.36z' fill='#1F2437' />
</g>
</svg>
)
}
export function ClayIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 400 400'>

View File

@@ -107,7 +107,6 @@ import {
SupabaseIcon,
TavilyIcon,
TelegramIcon,
TinybirdIcon,
TranslateIcon,
TrelloIcon,
TTSIcon,
@@ -231,8 +230,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
supabase: SupabaseIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
thinking: BrainIcon,
tinybird: TinybirdIcon,
translate: TranslateIcon,
trello: TrelloIcon,
tts: TTSIcon,

View File

@@ -12,7 +12,7 @@ Sim automatically calculates costs for all workflow executions, providing transp
Every workflow execution includes two cost components:
**Base Execution Charge**: $0.005 per execution
**Base Execution Charge**: $0.001 per execution
**AI Model Usage**: Variable cost based on token consumption
```javascript
@@ -48,40 +48,40 @@ The model breakdown shows:
<Tabs items={['Hosted Models', 'Bring Your Own API Key']}>
<Tab>
**Hosted Models** - Sim provides API keys with a 1.1x pricing multiplier for Agent blocks:
**Hosted Models** - Sim provides API keys with a 1.4x pricing multiplier for Agent blocks:
**OpenAI**
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|-------|---------------------------|----------------------------|
| GPT-5.1 | $1.25 / $10.00 | $1.38 / $11.00 |
| GPT-5 | $1.25 / $10.00 | $1.38 / $11.00 |
| GPT-5 Mini | $0.25 / $2.00 | $0.28 / $2.20 |
| GPT-5 Nano | $0.05 / $0.40 | $0.06 / $0.44 |
| GPT-4o | $2.50 / $10.00 | $2.75 / $11.00 |
| GPT-4.1 | $2.00 / $8.00 | $2.20 / $8.80 |
| GPT-4.1 Mini | $0.40 / $1.60 | $0.44 / $1.76 |
| GPT-4.1 Nano | $0.10 / $0.40 | $0.11 / $0.44 |
| o1 | $15.00 / $60.00 | $16.50 / $66.00 |
| o3 | $2.00 / $8.00 | $2.20 / $8.80 |
| o4 Mini | $1.10 / $4.40 | $1.21 / $4.84 |
| GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 |
| GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 |
| GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 |
| GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 |
| GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 |
| GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 |
| GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 |
| GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 |
| o1 | $15.00 / $60.00 | $21.00 / $84.00 |
| o3 | $2.00 / $8.00 | $2.80 / $11.20 |
| o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 |
**Anthropic**
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|-------|---------------------------|----------------------------|
| Claude Opus 4.5 | $5.00 / $25.00 | $5.50 / $27.50 |
| Claude Opus 4.1 | $15.00 / $75.00 | $16.50 / $82.50 |
| Claude Sonnet 4.5 | $3.00 / $15.00 | $3.30 / $16.50 |
| Claude Sonnet 4.0 | $3.00 / $15.00 | $3.30 / $16.50 |
| Claude Haiku 4.5 | $1.00 / $5.00 | $1.10 / $5.50 |
| Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 |
| Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 |
| Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 |
| Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 |
| Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 |
**Google**
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|-------|---------------------------|----------------------------|
| Gemini 3 Pro Preview | $2.00 / $12.00 | $2.20 / $13.20 |
| Gemini 2.5 Pro | $1.25 / $10.00 | $1.38 / $11.00 |
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.33 / $2.75 |
| Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 |
| Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 |
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 |
*The 1.1x multiplier covers infrastructure and API management costs.*
*The 1.4x multiplier covers infrastructure and API management costs.*
</Tab>
<Tab>

View File

@@ -44,8 +44,6 @@ Send a message to an external A2A-compatible agent.
| `message` | string | Yes | Message to send to the agent |
| `taskId` | string | No | Task ID for continuing an existing task |
| `contextId` | string | No | Context ID for conversation continuity |
| `data` | string | No | Structured data to include with the message \(JSON string\) |
| `files` | array | No | Files to include with the message |
| `apiKey` | string | No | API key for authentication |
#### Output
@@ -210,3 +208,8 @@ Delete the push notification webhook configuration for a task.
| `success` | boolean | Whether deletion was successful |
## Notes
- Category: `tools`
- Type: `a2a`

View File

@@ -49,7 +49,8 @@ Retrieves lead information by email address or lead ID.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Lemlist API key |
| `leadIdentifier` | string | Yes | Lead email address or lead ID |
| `email` | string | No | Lead email address \(use either email or id\) |
| `id` | string | No | Lead ID \(use either email or id\) |
#### Output

View File

@@ -103,8 +103,6 @@
"supabase",
"tavily",
"telegram",
"thinking",
"tinybird",
"translate",
"trello",
"tts",

View File

@@ -124,45 +124,6 @@ Read the latest messages from Slack channels. Retrieve conversation history with
| --------- | ---- | ----------- |
| `messages` | array | Array of message objects from the channel |
### `slack_get_message`
Retrieve a specific message by its timestamp. Useful for getting a thread parent message.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | No | Authentication method: oauth or bot_token |
| `botToken` | string | No | Bot token for Custom Bot |
| `channel` | string | Yes | Slack channel ID \(e.g., C1234567890\) |
| `timestamp` | string | Yes | Message timestamp to retrieve \(e.g., 1405894322.002768\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | object | The retrieved message object |
### `slack_get_thread`
Retrieve an entire thread including the parent message and all replies. Useful for getting full conversation context.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | No | Authentication method: oauth or bot_token |
| `botToken` | string | No | Bot token for Custom Bot |
| `channel` | string | Yes | Slack channel ID \(e.g., C1234567890\) |
| `threadTs` | string | Yes | Thread timestamp \(thread_ts\) to retrieve \(e.g., 1405894322.002768\) |
| `limit` | number | No | Maximum number of messages to return \(default: 100, max: 200\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `parentMessage` | object | The thread parent message |
### `slack_list_channels`
List all channels in a Slack workspace. Returns public and private channels the bot has access to.

View File

@@ -1,70 +0,0 @@
---
title: Tinybird
description: Send events and query data with Tinybird
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="tinybird"
color="#2EF598"
/>
## Usage Instructions
Interact with Tinybird using the Events API to stream JSON or NDJSON events, or use the Query API to execute SQL queries against Pipes and Data Sources.
## Tools
### `tinybird_events`
Send events to a Tinybird Data Source using the Events API. Supports JSON and NDJSON formats with optional gzip compression.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `base_url` | string | Yes | Tinybird API base URL \(e.g., https://api.tinybird.co or https://api.us-east.tinybird.co\) |
| `datasource` | string | Yes | Name of the Tinybird Data Source to send events to |
| `data` | string | Yes | Data to send as NDJSON \(newline-delimited JSON\) or JSON string. Each event should be a valid JSON object. |
| `wait` | boolean | No | Wait for database acknowledgment before responding. Enables safer retries but introduces latency. Defaults to false. |
| `format` | string | No | Format of the events data: "ndjson" \(default\) or "json" |
| `compression` | string | No | Compression format: "none" \(default\) or "gzip" |
| `token` | string | Yes | Tinybird API Token with DATASOURCE:APPEND or DATASOURCE:CREATE scope |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `successful_rows` | number | Number of rows successfully ingested |
| `quarantined_rows` | number | Number of rows quarantined \(failed validation\) |
### `tinybird_query`
Execute SQL queries against Tinybird Pipes and Data Sources using the Query API.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `base_url` | string | Yes | Tinybird API base URL \(e.g., https://api.tinybird.co\) |
| `query` | string | Yes | SQL query to execute. Specify your desired output format \(e.g., FORMAT JSON, FORMAT CSV, FORMAT TSV\). JSON format provides structured data, while other formats return raw text. |
| `pipeline` | string | No | Optional pipe name. When provided, enables SELECT * FROM _ syntax |
| `token` | string | Yes | Tinybird API Token with PIPE:READ scope |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | json | Query result data. For FORMAT JSON: array of objects. For other formats \(CSV, TSV, etc.\): raw text string. |
| `rows` | number | Number of rows returned \(only available with FORMAT JSON\) |
| `statistics` | json | Query execution statistics - elapsed time, rows read, bytes read \(only available with FORMAT JSON\) |
## Notes
- Category: `tools`
- Type: `tinybird`

View File

@@ -76,6 +76,14 @@
pointer-events: none;
}
/**
* Suppress the default selection ring for grouped selections
* These blocks show a more transparent ring via the component's ring overlay
*/
.react-flow__node.selected > div[data-grouped-selection="true"] > div::after {
box-shadow: none;
}
/**
* Color tokens - single source of truth for all colors
* Light mode: Warm theme

View File

@@ -0,0 +1,150 @@
import type {
Artifact,
Message,
Task,
TaskArtifactUpdateEvent,
TaskState,
TaskStatusUpdateEvent,
} from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('A2ASendMessageStreamAPI')
const A2ASendMessageStreamSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
message: z.string().min(1, 'Message is required'),
taskId: z.string().optional(),
contextId: z.string().optional(),
apiKey: z.string().optional(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(
`[${requestId}] Unauthorized A2A send message stream attempt: ${authResult.error}`
)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated A2A send message stream request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = A2ASendMessageStreamSchema.parse(body)
logger.info(`[${requestId}] Sending A2A streaming message`, {
agentUrl: validatedData.agentUrl,
hasTaskId: !!validatedData.taskId,
hasContextId: !!validatedData.contextId,
})
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const message: Message = {
kind: 'message',
messageId: crypto.randomUUID(),
role: 'user',
parts: [{ kind: 'text', text: validatedData.message }],
...(validatedData.taskId && { taskId: validatedData.taskId }),
...(validatedData.contextId && { contextId: validatedData.contextId }),
}
const stream = client.sendMessageStream({ message })
let taskId = ''
let contextId: string | undefined
let state: TaskState = 'working'
let content = ''
let artifacts: Artifact[] = []
let history: Message[] = []
for await (const event of stream) {
if (event.kind === 'message') {
const msg = event as Message
content = extractTextContent(msg)
taskId = msg.taskId || taskId
contextId = msg.contextId || contextId
state = 'completed'
} else if (event.kind === 'task') {
const task = event as Task
taskId = task.id
contextId = task.contextId
state = task.status.state
artifacts = task.artifacts || []
history = task.history || []
const lastAgentMessage = history.filter((m) => m.role === 'agent').pop()
if (lastAgentMessage) {
content = extractTextContent(lastAgentMessage)
}
} else if ('status' in event) {
const statusEvent = event as TaskStatusUpdateEvent
state = statusEvent.status.state
} else if ('artifact' in event) {
const artifactEvent = event as TaskArtifactUpdateEvent
artifacts.push(artifactEvent.artifact)
}
}
logger.info(`[${requestId}] A2A streaming message completed`, {
taskId,
state,
artifactCount: artifacts.length,
})
return NextResponse.json({
success: isTerminalState(state) && state !== 'failed',
output: {
content,
taskId,
contextId,
state,
artifacts,
history,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error in A2A streaming:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Streaming failed',
},
{ status: 500 }
)
}
}

View File

@@ -1,4 +1,4 @@
import type { DataPart, FilePart, Message, Part, Task, TextPart } from '@a2a-js/sdk'
import type { Message, Task } from '@a2a-js/sdk'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
@@ -10,20 +10,11 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('A2ASendMessageAPI')
const FileInputSchema = z.object({
type: z.enum(['file', 'url']),
data: z.string(),
name: z.string(),
mime: z.string().optional(),
})
const A2ASendMessageSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'),
message: z.string().min(1, 'Message is required'),
taskId: z.string().optional(),
contextId: z.string().optional(),
data: z.string().optional(),
files: z.array(FileInputSchema).optional(),
apiKey: z.string().optional(),
})
@@ -60,100 +51,18 @@ export async function POST(request: NextRequest) {
hasContextId: !!validatedData.contextId,
})
let client
try {
client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
logger.info(`[${requestId}] A2A client created successfully`)
} catch (clientError) {
logger.error(`[${requestId}] Failed to create A2A client:`, clientError)
return NextResponse.json(
{
success: false,
error: `Failed to connect to agent: ${clientError instanceof Error ? clientError.message : 'Unknown error'}`,
},
{ status: 502 }
)
}
const parts: Part[] = []
const textPart: TextPart = { kind: 'text', text: validatedData.message }
parts.push(textPart)
if (validatedData.data) {
try {
const parsedData = JSON.parse(validatedData.data)
const dataPart: DataPart = { kind: 'data', data: parsedData }
parts.push(dataPart)
} catch (parseError) {
logger.warn(`[${requestId}] Failed to parse data as JSON, skipping DataPart`, {
error: parseError instanceof Error ? parseError.message : String(parseError),
})
}
}
if (validatedData.files && validatedData.files.length > 0) {
for (const file of validatedData.files) {
if (file.type === 'url') {
const filePart: FilePart = {
kind: 'file',
file: {
name: file.name,
mimeType: file.mime,
uri: file.data,
},
}
parts.push(filePart)
} else if (file.type === 'file') {
let bytes = file.data
let mimeType = file.mime
if (file.data.startsWith('data:')) {
const match = file.data.match(/^data:([^;]+);base64,(.+)$/)
if (match) {
mimeType = mimeType || match[1]
bytes = match[2]
} else {
bytes = file.data
}
}
const filePart: FilePart = {
kind: 'file',
file: {
name: file.name,
mimeType: mimeType || 'application/octet-stream',
bytes,
},
}
parts.push(filePart)
}
}
}
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
const message: Message = {
kind: 'message',
messageId: crypto.randomUUID(),
role: 'user',
parts,
parts: [{ kind: 'text', text: validatedData.message }],
...(validatedData.taskId && { taskId: validatedData.taskId }),
...(validatedData.contextId && { contextId: validatedData.contextId }),
}
let result
try {
result = await client.sendMessage({ message })
logger.info(`[${requestId}] A2A sendMessage completed`, { resultKind: result?.kind })
} catch (sendError) {
logger.error(`[${requestId}] Failed to send A2A message:`, sendError)
return NextResponse.json(
{
success: false,
error: `Failed to send message: ${sendError instanceof Error ? sendError.message : 'Unknown error'}`,
},
{ status: 502 }
)
}
const result = await client.sendMessage({ message })
if (result.kind === 'message') {
const responseMessage = result as Message

View File

@@ -2,6 +2,13 @@ import { createSession, createWorkspaceRecord, loggerMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Tests for workspace invitation by ID API route
* Tests GET (details + token acceptance), DELETE (cancellation)
*
* @vitest-environment node
*/
const mockGetSession = vi.fn()
const mockHasWorkspaceAdminAccess = vi.fn()
@@ -220,7 +227,7 @@ describe('Workspace Invitation [invitationId] API Route', () => {
expect(response.headers.get('location')).toBe('https://test.sim.ai/workspace/workspace-456/w')
})
it('should redirect to error page with token preserved when invitation expired', async () => {
it('should redirect to error page when invitation expired', async () => {
const session = createSession({
userId: mockUser.id,
email: 'invited@example.com',
@@ -243,13 +250,12 @@ describe('Workspace Invitation [invitationId] API Route', () => {
const response = await GET(request, { params })
expect(response.status).toBe(307)
const location = response.headers.get('location')
expect(location).toBe(
'https://test.sim.ai/invite/invitation-789?error=expired&token=token-abc123'
expect(response.headers.get('location')).toBe(
'https://test.sim.ai/invite/invitation-789?error=expired'
)
})
it('should redirect to error page with token preserved when email mismatch', async () => {
it('should redirect to error page when email mismatch', async () => {
const session = createSession({
userId: mockUser.id,
email: 'wrong@example.com',
@@ -271,13 +277,12 @@ describe('Workspace Invitation [invitationId] API Route', () => {
const response = await GET(request, { params })
expect(response.status).toBe(307)
const location = response.headers.get('location')
expect(location).toBe(
'https://test.sim.ai/invite/invitation-789?error=email-mismatch&token=token-abc123'
expect(response.headers.get('location')).toBe(
'https://test.sim.ai/invite/invitation-789?error=email-mismatch'
)
})
it('should return 404 when invitation not found (without token)', async () => {
it('should return 404 when invitation not found', async () => {
const session = createSession({ userId: mockUser.id, email: mockUser.email })
mockGetSession.mockResolvedValue(session)
dbSelectResults = [[]]
@@ -291,189 +296,6 @@ describe('Workspace Invitation [invitationId] API Route', () => {
expect(response.status).toBe(404)
expect(data).toEqual({ error: 'Invitation not found or has expired' })
})
it('should redirect to error page with token preserved when invitation not found (with token)', async () => {
const session = createSession({ userId: mockUser.id, email: mockUser.email })
mockGetSession.mockResolvedValue(session)
dbSelectResults = [[]]
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/non-existent?token=some-invalid-token'
)
const params = Promise.resolve({ invitationId: 'non-existent' })
const response = await GET(request, { params })
expect(response.status).toBe(307)
const location = response.headers.get('location')
expect(location).toBe(
'https://test.sim.ai/invite/non-existent?error=invalid-token&token=some-invalid-token'
)
})
it('should redirect to error page with token preserved when invitation already processed', async () => {
const session = createSession({
userId: mockUser.id,
email: 'invited@example.com',
name: mockUser.name,
})
mockGetSession.mockResolvedValue(session)
const acceptedInvitation = {
...mockInvitation,
status: 'accepted',
}
dbSelectResults = [[acceptedInvitation], [mockWorkspace]]
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
)
const params = Promise.resolve({ invitationId: 'token-abc123' })
const response = await GET(request, { params })
expect(response.status).toBe(307)
const location = response.headers.get('location')
expect(location).toBe(
'https://test.sim.ai/invite/invitation-789?error=already-processed&token=token-abc123'
)
})
it('should redirect to error page with token preserved when workspace not found', async () => {
const session = createSession({
userId: mockUser.id,
email: 'invited@example.com',
name: mockUser.name,
})
mockGetSession.mockResolvedValue(session)
dbSelectResults = [[mockInvitation], []]
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
)
const params = Promise.resolve({ invitationId: 'token-abc123' })
const response = await GET(request, { params })
expect(response.status).toBe(307)
const location = response.headers.get('location')
expect(location).toBe(
'https://test.sim.ai/invite/invitation-789?error=workspace-not-found&token=token-abc123'
)
})
it('should redirect to error page with token preserved when user not found', async () => {
const session = createSession({
userId: mockUser.id,
email: 'invited@example.com',
name: mockUser.name,
})
mockGetSession.mockResolvedValue(session)
dbSelectResults = [[mockInvitation], [mockWorkspace], []]
const request = new NextRequest(
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
)
const params = Promise.resolve({ invitationId: 'token-abc123' })
const response = await GET(request, { params })
expect(response.status).toBe(307)
const location = response.headers.get('location')
expect(location).toBe(
'https://test.sim.ai/invite/invitation-789?error=user-not-found&token=token-abc123'
)
})
it('should URL encode special characters in token when preserving in error redirects', async () => {
const session = createSession({
userId: mockUser.id,
email: 'wrong@example.com',
name: mockUser.name,
})
mockGetSession.mockResolvedValue(session)
dbSelectResults = [
[mockInvitation],
[mockWorkspace],
[{ ...mockUser, email: 'wrong@example.com' }],
]
const specialToken = 'token+with/special=chars&more'
const request = new NextRequest(
`http://localhost/api/workspaces/invitations/token-abc123?token=${encodeURIComponent(specialToken)}`
)
const params = Promise.resolve({ invitationId: 'token-abc123' })
const response = await GET(request, { params })
expect(response.status).toBe(307)
const location = response.headers.get('location')
expect(location).toContain('error=email-mismatch')
expect(location).toContain(`token=${encodeURIComponent(specialToken)}`)
})
})
describe('Token Preservation - Full Flow Scenario', () => {
it('should preserve token through email mismatch so user can retry with correct account', async () => {
const wrongSession = createSession({
userId: 'wrong-user',
email: 'wrong@example.com',
name: 'Wrong User',
})
mockGetSession.mockResolvedValue(wrongSession)
dbSelectResults = [
[mockInvitation],
[mockWorkspace],
[{ id: 'wrong-user', email: 'wrong@example.com' }],
]
const request1 = new NextRequest(
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
)
const params1 = Promise.resolve({ invitationId: 'token-abc123' })
const response1 = await GET(request1, { params: params1 })
expect(response1.status).toBe(307)
const location1 = response1.headers.get('location')
expect(location1).toBe(
'https://test.sim.ai/invite/invitation-789?error=email-mismatch&token=token-abc123'
)
vi.clearAllMocks()
dbSelectCallIndex = 0
const correctSession = createSession({
userId: mockUser.id,
email: 'invited@example.com',
name: mockUser.name,
})
mockGetSession.mockResolvedValue(correctSession)
dbSelectResults = [
[mockInvitation],
[mockWorkspace],
[{ ...mockUser, email: 'invited@example.com' }],
[],
]
const request2 = new NextRequest(
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
)
const params2 = Promise.resolve({ invitationId: 'token-abc123' })
const response2 = await GET(request2, { params: params2 })
expect(response2.status).toBe(307)
expect(response2.headers.get('location')).toBe(
'https://test.sim.ai/workspace/workspace-456/w'
)
})
})
describe('DELETE /api/workspaces/invitations/[invitationId]', () => {

View File

@@ -31,6 +31,7 @@ export async function GET(
const isAcceptFlow = !!token // If token is provided, this is an acceptance flow
if (!session?.user?.id) {
// For token-based acceptance flows, redirect to login
if (isAcceptFlow) {
return NextResponse.redirect(new URL(`/invite/${invitationId}?token=${token}`, getBaseUrl()))
}
@@ -50,9 +51,8 @@ export async function GET(
if (!invitation) {
if (isAcceptFlow) {
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
return NextResponse.redirect(
new URL(`/invite/${invitationId}?error=invalid-token${tokenParam}`, getBaseUrl())
new URL(`/invite/${invitationId}?error=invalid-token`, getBaseUrl())
)
}
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
@@ -60,9 +60,8 @@ export async function GET(
if (new Date() > new Date(invitation.expiresAt)) {
if (isAcceptFlow) {
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
return NextResponse.redirect(
new URL(`/invite/${invitation.id}?error=expired${tokenParam}`, getBaseUrl())
new URL(`/invite/${invitation.id}?error=expired`, getBaseUrl())
)
}
return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 })
@@ -76,20 +75,17 @@ export async function GET(
if (!workspaceDetails) {
if (isAcceptFlow) {
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
return NextResponse.redirect(
new URL(`/invite/${invitation.id}?error=workspace-not-found${tokenParam}`, getBaseUrl())
new URL(`/invite/${invitation.id}?error=workspace-not-found`, getBaseUrl())
)
}
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (isAcceptFlow) {
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) {
return NextResponse.redirect(
new URL(`/invite/${invitation.id}?error=already-processed${tokenParam}`, getBaseUrl())
new URL(`/invite/${invitation.id}?error=already-processed`, getBaseUrl())
)
}
@@ -104,7 +100,7 @@ export async function GET(
if (!userData) {
return NextResponse.redirect(
new URL(`/invite/${invitation.id}?error=user-not-found${tokenParam}`, getBaseUrl())
new URL(`/invite/${invitation.id}?error=user-not-found`, getBaseUrl())
)
}
@@ -112,7 +108,7 @@ export async function GET(
if (!isValidMatch) {
return NextResponse.redirect(
new URL(`/invite/${invitation.id}?error=email-mismatch${tokenParam}`, getBaseUrl())
new URL(`/invite/${invitation.id}?error=email-mismatch`, getBaseUrl())
)
}

View File

@@ -178,25 +178,23 @@ export default function Invite() {
useEffect(() => {
const errorReason = searchParams.get('error')
const isNew = searchParams.get('new') === 'true'
setIsNewUser(isNew)
const tokenFromQuery = searchParams.get('token')
if (tokenFromQuery) {
setToken(tokenFromQuery)
sessionStorage.setItem('inviteToken', tokenFromQuery)
} else {
const storedToken = sessionStorage.getItem('inviteToken')
if (storedToken && storedToken !== inviteId) {
setToken(storedToken)
}
}
if (errorReason) {
setError(getInviteError(errorReason))
setIsLoading(false)
return
}
const isNew = searchParams.get('new') === 'true'
setIsNewUser(isNew)
const tokenFromQuery = searchParams.get('token')
const effectiveToken = tokenFromQuery || inviteId
if (effectiveToken) {
setToken(effectiveToken)
sessionStorage.setItem('inviteToken', effectiveToken)
}
}, [searchParams, inviteId])
useEffect(() => {
@@ -205,6 +203,7 @@ export default function Invite() {
async function fetchInvitationDetails() {
setIsLoading(true)
try {
// Fetch invitation details using the invitation ID from the URL path
const workspaceInviteResponse = await fetch(`/api/workspaces/invitations/${inviteId}`, {
method: 'GET',
})
@@ -221,6 +220,7 @@ export default function Invite() {
return
}
// Handle workspace invitation errors with specific status codes
if (!workspaceInviteResponse.ok && workspaceInviteResponse.status !== 404) {
const errorCode = parseApiError(null, workspaceInviteResponse.status)
const errorData = await workspaceInviteResponse.json().catch(() => ({}))
@@ -229,6 +229,7 @@ export default function Invite() {
error: errorData,
})
// Refine error code based on response body if available
if (errorData.error) {
const refinedCode = parseApiError(errorData.error, workspaceInviteResponse.status)
setError(getInviteError(refinedCode))
@@ -253,11 +254,13 @@ export default function Invite() {
if (data) {
setInvitationType('organization')
// Check if user is already in an organization BEFORE showing the invitation
const activeOrgResponse = await client.organization
.getFullOrganization()
.catch(() => ({ data: null }))
if (activeOrgResponse?.data) {
// User is already in an organization
setCurrentOrgName(activeOrgResponse.data.name)
setError(getInviteError('already-in-organization'))
setIsLoading(false)
@@ -286,6 +289,7 @@ export default function Invite() {
throw { code: 'invalid-invitation' }
}
} catch (orgErr: any) {
// If this is our structured error, use it directly
if (orgErr.code) {
throw orgErr
}
@@ -312,6 +316,7 @@ export default function Invite() {
window.location.href = `/api/workspaces/invitations/${encodeURIComponent(inviteId)}?token=${encodeURIComponent(token || '')}`
} else {
try {
// Get the organizationId from invitation details
const orgId = invitationDetails?.data?.organizationId
if (!orgId) {
@@ -320,6 +325,7 @@ export default function Invite() {
return
}
// Use our custom API endpoint that handles Pro usage snapshot
const response = await fetch(`/api/organizations/${orgId}/invitations/${inviteId}`, {
method: 'PUT',
headers: {
@@ -341,6 +347,7 @@ export default function Invite() {
return
}
// Set the organization as active
await client.organization.setActive({
organizationId: orgId,
})
@@ -353,6 +360,7 @@ export default function Invite() {
} catch (err: any) {
logger.error('Error accepting invitation:', err)
// Reset accepted state on error
setAccepted(false)
const errorCode = parseApiError(err)
@@ -363,9 +371,7 @@ export default function Invite() {
}
const getCallbackUrl = () => {
const effectiveToken =
token || sessionStorage.getItem('inviteToken') || searchParams.get('token')
return `/invite/${inviteId}${effectiveToken && effectiveToken !== inviteId ? `?token=${effectiveToken}` : ''}`
return `/invite/${inviteId}${token && token !== inviteId ? `?token=${token}` : ''}`
}
if (!session?.user && !isPending) {
@@ -429,6 +435,7 @@ export default function Invite() {
if (error) {
const callbackUrl = encodeURIComponent(getCallbackUrl())
// Special handling for already in organization
if (error.code === 'already-in-organization') {
return (
<InviteLayout>
@@ -456,6 +463,7 @@ export default function Invite() {
)
}
// Handle email mismatch - user needs to sign in with a different account
if (error.code === 'email-mismatch') {
return (
<InviteLayout>
@@ -482,6 +490,7 @@ export default function Invite() {
)
}
// Handle auth-related errors - prompt user to sign in
if (error.requiresAuth) {
return (
<InviteLayout>
@@ -509,6 +518,7 @@ export default function Invite() {
)
}
// Handle retryable errors
const actions: Array<{
label: string
onClick: () => void
@@ -540,6 +550,7 @@ export default function Invite() {
)
}
// Show success only if accepted AND no error
if (accepted && !error) {
return (
<InviteLayout>

View File

@@ -221,9 +221,7 @@ export function Chat() {
exportChatCSV,
} = useChatStore()
const hasConsoleHydrated = useTerminalConsoleStore((state) => state._hasHydrated)
const entriesFromStore = useTerminalConsoleStore((state) => state.entries)
const entries = hasConsoleHydrated ? entriesFromStore : []
const { entries } = useTerminalConsoleStore()
const { isExecuting } = useExecutionStore()
const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution()
const { data: session } = useSession()
@@ -533,6 +531,35 @@ export function Chat() {
return
}
if (
selectedOutputs.length > 0 &&
'logs' in result &&
Array.isArray(result.logs) &&
activeWorkflowId
) {
const additionalOutputs: string[] = []
for (const outputId of selectedOutputs) {
const blockId = extractBlockIdFromOutputId(outputId)
const path = extractPathFromOutputId(outputId, blockId)
if (path === 'content') continue
const outputValue = extractOutputFromLogs(result.logs as BlockLog[], outputId)
if (outputValue !== undefined) {
const formattedValue =
typeof outputValue === 'string' ? outputValue : JSON.stringify(outputValue)
if (formattedValue) {
additionalOutputs.push(`**${path}:** ${formattedValue}`)
}
}
}
if (additionalOutputs.length > 0) {
appendMessageContent(responseMessageId, `\n\n${additionalOutputs.join('\n\n')}`)
}
}
finalizeMessageStream(responseMessageId)
} else if (contentChunk) {
accumulatedContent += contentChunk

View File

@@ -29,6 +29,8 @@ export function BlockContextMenu({
onRemoveFromSubflow,
onOpenEditor,
onRename,
onGroupBlocks,
onUngroupBlocks,
hasClipboard = false,
showRemoveFromSubflow = false,
disableEdit = false,
@@ -47,6 +49,14 @@ export function BlockContextMenu({
const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock
// Check if we can group: need at least 2 blocks selected
const canGroup = selectedBlocks.length >= 2
// Check if we can ungroup: at least one selected block must be in a group
// Ungrouping will ungroup all blocks in that group (the entire group, not just selected blocks)
const hasGroupedBlock = selectedBlocks.some((b) => !!b.groupId)
const canUngroup = hasGroupedBlock
const getToggleEnabledLabel = () => {
if (allEnabled) return 'Disable'
if (allDisabled) return 'Enable'
@@ -141,6 +151,31 @@ export function BlockContextMenu({
</PopoverItem>
)}
{/* Block group actions */}
{(canGroup || canUngroup) && <PopoverDivider />}
{canGroup && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onGroupBlocks()
onClose()
}}
>
Group Blocks
</PopoverItem>
)}
{canUngroup && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onUngroupBlocks()
onClose()
}}
>
Ungroup
</PopoverItem>
)}
{/* Single block actions */}
{isSingleBlock && <PopoverDivider />}
{isSingleBlock && !isSubflow && (

View File

@@ -24,6 +24,8 @@ export interface ContextMenuBlockInfo {
parentId?: string
/** Parent type ('loop' | 'parallel') if nested */
parentType?: string
/** Group ID if block is in a group */
groupId?: string
}
/**
@@ -50,6 +52,8 @@ export interface BlockContextMenuProps {
onRemoveFromSubflow: () => void
onOpenEditor: () => void
onRename: () => void
onGroupBlocks: () => void
onUngroupBlocks: () => void
/** Whether clipboard has content for pasting */
hasClipboard?: boolean
/** Whether remove from subflow option should be shown */

View File

@@ -1,6 +1,6 @@
import { memo, useCallback, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import type { NodeProps } from 'reactflow'
import { type NodeProps, useReactFlow } from 'reactflow'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/core/utils/cn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -10,6 +10,7 @@ import {
useBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { ActionBar } from '../workflow-block/components'
import type { WorkflowBlockProps } from '../workflow-block/types'
@@ -198,6 +199,57 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlo
const userPermissions = useUserPermissionsContext()
// Get React Flow methods for group selection expansion
const { getNodes, setNodes } = useReactFlow()
const { getGroups } = useWorkflowStore()
/**
* Expands selection to include all group members on mouse down.
* This ensures that when a user starts dragging a note in a group,
* all other blocks in the group are also selected and will move together.
*/
const handleGroupMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only process left mouse button clicks
if (e.button !== 0) return
const groupId = data.groupId
if (!groupId) return
const groups = getGroups()
const group = groups[groupId]
if (!group || group.blockIds.length <= 1) return
const groupBlockIds = new Set(group.blockIds)
const allNodes = getNodes()
// Check if all group members are already selected
const allSelected = [...groupBlockIds].every((blockId) =>
allNodes.find((n) => n.id === blockId && n.selected)
)
if (allSelected) return
// Expand selection to include all group members
setNodes((nodes) =>
nodes.map((n) => {
const isInGroup = groupBlockIds.has(n.id)
const isThisBlock = n.id === id
return {
...n,
selected: isInGroup ? true : n.selected,
data: {
...n.data,
// Mark as grouped selection if in group but not the directly clicked block
isGroupedSelection: isInGroup && !isThisBlock && !n.selected,
},
}
})
)
},
[id, data.groupId, getNodes, setNodes, getGroups]
)
/**
* Calculate deterministic dimensions based on content structure.
* Uses fixed width and computed height to avoid ResizeObserver jitter.
@@ -216,8 +268,14 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlo
dependencies: [isEmpty],
})
const isGroupedSelection = data.isGroupedSelection ?? false
return (
<div className='group relative'>
<div
className='group relative'
data-grouped-selection={isGroupedSelection ? 'true' : undefined}
onMouseDown={handleGroupMouseDown}
>
<div
className={cn(
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] border border-[var(--border)] bg-[var(--surface-2)]'

View File

@@ -8,6 +8,7 @@ import { Button, Code, getCodeEditorProps, highlight, languages } from '@/compon
import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
import { getClientTool } from '@/lib/copilot/tools/client/manager'
import { getRegisteredTools } from '@/lib/copilot/tools/client/registry'
// Initialize all tool UI configs
import '@/lib/copilot/tools/client/init-tool-configs'
import {
getSubagentLabels as getSubagentLabelsFromConfig,

View File

@@ -1,6 +1,6 @@
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 { 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'
export { SlashMenu } from './slash-menu/slash-menu'

View File

@@ -1,151 +0,0 @@
'use client'
import type { ComponentType, ReactNode, SVGProps } from 'react'
import { PopoverItem } from '@/components/emcn'
import { formatCompactTimestamp } from '@/lib/core/utils/formatting'
import {
FOLDER_CONFIGS,
MENU_STATE_TEXT_CLASSES,
type MentionFolderId,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
const ICON_CONTAINER =
'relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
export function BlockIcon({
bgColor,
Icon,
}: {
bgColor?: string
Icon?: ComponentType<SVGProps<SVGSVGElement>>
}) {
return (
<div className={ICON_CONTAINER} style={{ background: bgColor || '#6B7280' }}>
{Icon && <Icon className='!h-[10px] !w-[10px] !text-white' />}
</div>
)
}
export function WorkflowColorDot({ color }: { color?: string }) {
return <div className={ICON_CONTAINER} style={{ backgroundColor: color || '#3972F6' }} />
}
interface FolderContentProps {
/** Folder ID to render content for */
folderId: MentionFolderId
/** Items to render (already filtered) */
items: any[]
/** Whether data is loading */
isLoading: boolean
/** Current search query (for determining empty vs no-match message) */
currentQuery: string
/** Currently active item index (for keyboard navigation) */
activeIndex: number
/** Callback when an item is clicked */
onItemClick: (item: any) => void
}
export function renderItemIcon(folderId: MentionFolderId, item: any): ReactNode {
switch (folderId) {
case 'workflows':
return <WorkflowColorDot color={item.color} />
case 'blocks':
case 'workflow-blocks':
return <BlockIcon bgColor={item.bgColor} Icon={item.iconComponent} />
default:
return null
}
}
function renderItemSuffix(folderId: MentionFolderId, item: any): ReactNode {
switch (folderId) {
case 'templates':
return <span className='text-[10px] text-[var(--text-muted)]'>{item.stars}</span>
case 'logs':
return (
<>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatCompactTimestamp(item.createdAt)}
</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='text-[10px] capitalize'>{(item.trigger || 'manual').toLowerCase()}</span>
</>
)
default:
return null
}
}
export function FolderContent({
folderId,
items,
isLoading,
currentQuery,
activeIndex,
onItemClick,
}: FolderContentProps) {
const config = FOLDER_CONFIGS[folderId]
if (isLoading) {
return <div className={MENU_STATE_TEXT_CLASSES}>Loading...</div>
}
if (items.length === 0) {
return (
<div className={MENU_STATE_TEXT_CLASSES}>
{currentQuery ? config.noMatchMessage : config.emptyMessage}
</div>
)
}
return (
<>
{items.map((item, index) => (
<PopoverItem
key={config.getId(item)}
onClick={() => onItemClick(item)}
data-idx={index}
active={index === activeIndex}
>
{renderItemIcon(folderId, item)}
<span className={folderId === 'logs' ? 'min-w-0 flex-1 truncate' : 'truncate'}>
{config.getLabel(item)}
</span>
{renderItemSuffix(folderId, item)}
</PopoverItem>
))}
</>
)
}
export function FolderPreviewContent({
folderId,
items,
isLoading,
onItemClick,
}: Omit<FolderContentProps, 'currentQuery' | 'activeIndex'>) {
const config = FOLDER_CONFIGS[folderId]
if (isLoading) {
return <div className={MENU_STATE_TEXT_CLASSES}>Loading...</div>
}
if (items.length === 0) {
return <div className={MENU_STATE_TEXT_CLASSES}>{config.emptyMessage}</div>
}
return (
<>
{items.map((item) => (
<PopoverItem key={config.getId(item)} onClick={() => onItemClick(item)}>
{renderItemIcon(folderId, item)}
<span className={folderId === 'logs' ? 'min-w-0 flex-1 truncate' : 'truncate'}>
{config.getLabel(item)}
</span>
{renderItemSuffix(folderId, item)}
</PopoverItem>
))}
</>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo } from 'react'
import { useMemo } from 'react'
import {
Popover,
PopoverAnchor,
@@ -9,43 +9,59 @@ import {
PopoverFolder,
PopoverItem,
PopoverScrollArea,
usePopoverContext,
} from '@/components/emcn'
import { formatCompactTimestamp } from '@/lib/core/utils/formatting'
import {
FOLDER_CONFIGS,
FOLDER_ORDER,
MENU_STATE_TEXT_CLASSES,
type MentionCategory,
type MentionFolderId,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import {
useCaretViewport,
type useMentionData,
type useMentionMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import {
getFolderData as getFolderDataUtil,
getFolderEnsureLoaded as getFolderEnsureLoadedUtil,
getFolderLoading as getFolderLoadingUtil,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import { FolderContent, FolderPreviewContent, renderItemIcon } from './folder-content'
import type { useMentionData } from '../../hooks/use-mention-data'
import type { useMentionMenu } from '../../hooks/use-mention-menu'
function formatTimestamp(iso: string): string {
try {
const d = new Date(iso)
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${mm}-${dd} ${hh}:${min}`
} catch {
return iso
}
}
/**
* Common text styling for loading and empty states
*/
const STATE_TEXT_CLASSES = 'px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'
/**
* Loading state component for mention folders
*/
const LoadingState = () => <div className={STATE_TEXT_CLASSES}>Loading...</div>
/**
* Empty state component for mention folders
*/
const EmptyState = ({ message }: { message: string }) => (
<div className={STATE_TEXT_CLASSES}>{message}</div>
)
/**
* Aggregated item type for filtered results
*/
interface AggregatedItem {
id: string
label: string
category: MentionCategory
category:
| 'chats'
| 'workflows'
| 'knowledge'
| 'blocks'
| 'workflow-blocks'
| 'templates'
| 'logs'
| 'docs'
data: any
icon?: React.ReactNode
}
export interface MentionFolderNav {
isInFolder: boolean
currentFolder: string | null
openFolder: (id: string, title: string) => void
closeFolder: () => void
}
interface MentionMenuProps {
mentionMenu: ReturnType<typeof useMentionMenu>
mentionData: ReturnType<typeof useMentionData>
@@ -60,124 +76,177 @@ interface MentionMenuProps {
insertLogMention: (log: any) => void
insertDocsMention: () => void
}
onFolderNavChange?: (nav: MentionFolderNav) => void
}
type InsertHandlerMap = Record<MentionFolderId, (item: any) => void>
function MentionMenuContent({
/**
* MentionMenu component for mention menu dropdown.
* Handles rendering of mention options, submenus, and aggregated search results.
* Manages keyboard navigation and selection of mentions.
*
* @param props - Component props
* @returns Rendered mention menu
*/
export function MentionMenu({
mentionMenu,
mentionData,
message,
insertHandlers,
onFolderNavChange,
}: MentionMenuProps) {
const { currentFolder, openFolder, closeFolder } = usePopoverContext()
const {
mentionMenuRef,
menuListRef,
getActiveMentionQueryAtPosition,
getCaretPos,
submenuActiveIndex,
mentionActiveIndex,
setSubmenuActiveIndex,
openSubmenuFor,
} = mentionMenu
const {
insertPastChatMention,
insertWorkflowMention,
insertKnowledgeMention,
insertBlockMention,
insertWorkflowBlockMention,
insertTemplateMention,
insertLogMention,
insertDocsMention,
} = insertHandlers
/**
* Get the current query string after @
*/
const currentQuery = useMemo(() => {
const caretPos = getCaretPos()
const active = getActiveMentionQueryAtPosition(caretPos, message)
return active?.query.trim().toLowerCase() || ''
}, [message, getCaretPos, getActiveMentionQueryAtPosition])
const isInFolder = currentFolder !== null
const showAggregatedView = currentQuery.length > 0
const isInFolderNavigationMode = !isInFolder && !showAggregatedView
useEffect(() => {
setSubmenuActiveIndex(0)
}, [isInFolder, setSubmenuActiveIndex])
useEffect(() => {
if (onFolderNavChange) {
onFolderNavChange({
isInFolder,
currentFolder,
openFolder,
closeFolder,
})
}
}, [onFolderNavChange, isInFolder, currentFolder, openFolder, closeFolder])
const insertHandlerMap = useMemo(
(): InsertHandlerMap => ({
chats: insertHandlers.insertPastChatMention,
workflows: insertHandlers.insertWorkflowMention,
knowledge: insertHandlers.insertKnowledgeMention,
blocks: insertHandlers.insertBlockMention,
'workflow-blocks': insertHandlers.insertWorkflowBlockMention,
templates: insertHandlers.insertTemplateMention,
logs: insertHandlers.insertLogMention,
}),
[insertHandlers]
)
const getFolderData = useCallback(
(folderId: MentionFolderId) => getFolderDataUtil(mentionData, folderId),
[mentionData]
)
const getFolderLoading = useCallback(
(folderId: MentionFolderId) => getFolderLoadingUtil(mentionData, folderId),
[mentionData]
)
const getEnsureLoaded = useCallback(
(folderId: MentionFolderId) => getFolderEnsureLoadedUtil(mentionData, folderId),
[mentionData]
)
const filterFolderItems = useCallback(
(folderId: MentionFolderId, query: string): any[] => {
const config = FOLDER_CONFIGS[folderId]
const items = getFolderData(folderId)
if (!query) return items
const q = query.toLowerCase()
return items.filter((item) => config.filterFn(item, q))
},
[getFolderData]
)
const getFilteredFolderItems = useCallback(
(folderId: MentionFolderId): any[] => {
return isInFolder ? filterFolderItems(folderId, currentQuery) : getFolderData(folderId)
},
[isInFolder, currentQuery, filterFolderItems, getFolderData]
)
/**
* Collect and filter all available items based on query
*/
const filteredAggregatedItems = useMemo(() => {
if (!currentQuery) return []
const items: AggregatedItem[] = []
const q = currentQuery.toLowerCase()
for (const folderId of FOLDER_ORDER) {
const config = FOLDER_CONFIGS[folderId]
const folderData = getFolderData(folderId)
// Chats
mentionData.pastChats.forEach((chat) => {
const label = chat.title || 'New Chat'
if (label.toLowerCase().includes(currentQuery)) {
items.push({
id: `chat-${chat.id}`,
label,
category: 'chats',
data: chat,
})
}
})
folderData.forEach((item) => {
if (config.filterFn(item, q)) {
items.push({
id: `${folderId}-${config.getId(item)}`,
label: config.getLabel(item),
category: folderId as MentionCategory,
data: item,
icon: renderItemIcon(folderId, item),
})
}
})
}
// Workflows
mentionData.workflows.forEach((wf) => {
const label = wf.name || 'Untitled Workflow'
if (label.toLowerCase().includes(currentQuery)) {
items.push({
id: `workflow-${wf.id}`,
label,
category: 'workflows',
data: wf,
icon: (
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ backgroundColor: wf.color || '#3972F6' }}
/>
),
})
}
})
if ('docs'.includes(q)) {
// Knowledge bases
mentionData.knowledgeBases.forEach((kb) => {
const label = kb.name || 'Untitled'
if (label.toLowerCase().includes(currentQuery)) {
items.push({
id: `knowledge-${kb.id}`,
label,
category: 'knowledge',
data: kb,
})
}
})
// Blocks
mentionData.blocksList.forEach((blk) => {
const label = blk.name || blk.id
if (label.toLowerCase().includes(currentQuery)) {
const Icon = blk.iconComponent
items.push({
id: `block-${blk.id}`,
label,
category: 'blocks',
data: blk,
icon: (
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
),
})
}
})
// Workflow blocks
mentionData.workflowBlocks.forEach((blk) => {
const label = blk.name || blk.id
if (label.toLowerCase().includes(currentQuery)) {
const Icon = blk.iconComponent
items.push({
id: `workflow-block-${blk.id}`,
label,
category: 'workflow-blocks',
data: blk,
icon: (
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
),
})
}
})
// Templates
mentionData.templatesList.forEach((tpl) => {
const label = tpl.name
if (label.toLowerCase().includes(currentQuery)) {
items.push({
id: `template-${tpl.id}`,
label,
category: 'templates',
data: tpl,
})
}
})
// Logs
mentionData.logsList.forEach((log) => {
const label = log.workflowName
if (label.toLowerCase().includes(currentQuery)) {
items.push({
id: `log-${log.id}`,
label,
category: 'logs',
data: log,
})
}
})
// Docs
if ('docs'.includes(currentQuery)) {
items.push({
id: 'docs',
label: 'Docs',
@@ -187,114 +256,124 @@ function MentionMenuContent({
}
return items
}, [currentQuery, getFolderData])
}, [currentQuery, mentionData])
const handleAggregatedItemClick = useCallback(
(item: AggregatedItem) => {
if (item.category === 'docs') {
insertHandlers.insertDocsMention()
return
}
const handler = insertHandlerMap[item.category as MentionFolderId]
if (handler) {
handler(item.data)
}
},
[insertHandlerMap, insertHandlers]
)
/**
* Handle click on aggregated item
*/
const handleAggregatedItemClick = (item: AggregatedItem) => {
switch (item.category) {
case 'chats':
insertPastChatMention(item.data)
break
case 'workflows':
insertWorkflowMention(item.data)
break
case 'knowledge':
insertKnowledgeMention(item.data)
break
case 'blocks':
insertBlockMention(item.data)
break
case 'workflow-blocks':
insertWorkflowBlockMention(item.data)
break
case 'templates':
insertTemplateMention(item.data)
break
case 'logs':
insertLogMention(item.data)
break
case 'docs':
insertDocsMention()
break
}
}
return (
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{isInFolder ? (
<FolderContent
folderId={currentFolder as MentionFolderId}
items={getFilteredFolderItems(currentFolder as MentionFolderId)}
isLoading={getFolderLoading(currentFolder as MentionFolderId)}
currentQuery={currentQuery}
activeIndex={submenuActiveIndex}
onItemClick={insertHandlerMap[currentFolder as MentionFolderId]}
/>
) : showAggregatedView ? (
<>
{filteredAggregatedItems.length === 0 ? (
<div className={MENU_STATE_TEXT_CLASSES}>No results found</div>
) : (
filteredAggregatedItems.map((item, index) => (
<PopoverItem
key={item.id}
onClick={() => handleAggregatedItemClick(item)}
data-idx={index}
active={index === submenuActiveIndex}
>
{item.icon}
<span className='flex-1 truncate'>{item.label}</span>
{item.category === 'logs' && (
<>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatCompactTimestamp(item.data.createdAt)}
</span>
</>
)}
</PopoverItem>
))
)}
</>
) : (
<>
{FOLDER_ORDER.map((folderId, folderIndex) => {
const config = FOLDER_CONFIGS[folderId]
const ensureLoaded = getEnsureLoaded(folderId)
// Open state derived directly from mention menu
const open = !!mentionMenu.showMentionMenu
return (
<PopoverFolder
key={folderId}
id={folderId}
title={config.title}
onOpen={() => ensureLoaded?.()}
active={isInFolderNavigationMode && mentionActiveIndex === folderIndex}
data-idx={folderIndex}
>
<FolderPreviewContent
folderId={folderId}
items={getFolderData(folderId)}
isLoading={getFolderLoading(folderId)}
onItemClick={insertHandlerMap[folderId]}
/>
</PopoverFolder>
)
})}
// Show filtered aggregated view when there's a query
const showAggregatedView = currentQuery.length > 0
<PopoverItem
rootOnly
onClick={() => insertHandlers.insertDocsMention()}
active={isInFolderNavigationMode && mentionActiveIndex === FOLDER_ORDER.length}
data-idx={FOLDER_ORDER.length}
>
<span>Docs</span>
</PopoverItem>
</>
)}
</PopoverScrollArea>
)
}
// Folder order for keyboard navigation - matches render order
const FOLDER_ORDER = [
'Chats', // 0
'Workflows', // 1
'Knowledge', // 2
'Blocks', // 3
'Workflow Blocks', // 4
'Templates', // 5
'Logs', // 6
'Docs', // 7
] as const
export function MentionMenu({
mentionMenu,
mentionData,
message,
insertHandlers,
onFolderNavChange,
}: MentionMenuProps) {
const { mentionMenuRef, textareaRef, getCaretPos } = mentionMenu
// Get active folder based on navigation when not in submenu and no query
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
// Compute caret viewport position via mirror technique for precise anchoring
const textareaEl = mentionMenu.textareaRef.current
if (!textareaEl) return null
const getCaretViewport = (textarea: HTMLTextAreaElement, caretPosition: number, text: string) => {
const textareaRect = textarea.getBoundingClientRect()
const style = window.getComputedStyle(textarea)
const mirrorDiv = document.createElement('div')
mirrorDiv.style.position = 'absolute'
mirrorDiv.style.visibility = 'hidden'
mirrorDiv.style.whiteSpace = 'pre-wrap'
mirrorDiv.style.wordWrap = 'break-word'
mirrorDiv.style.font = style.font
mirrorDiv.style.padding = style.padding
mirrorDiv.style.border = style.border
mirrorDiv.style.width = style.width
mirrorDiv.style.lineHeight = style.lineHeight
mirrorDiv.style.boxSizing = style.boxSizing
mirrorDiv.style.letterSpacing = style.letterSpacing
mirrorDiv.style.textTransform = style.textTransform
mirrorDiv.style.textIndent = style.textIndent
mirrorDiv.style.textAlign = style.textAlign
mirrorDiv.textContent = text.substring(0, caretPosition)
const caretMarker = document.createElement('span')
caretMarker.style.display = 'inline-block'
caretMarker.style.width = '0px'
caretMarker.style.padding = '0'
caretMarker.style.border = '0'
mirrorDiv.appendChild(caretMarker)
document.body.appendChild(mirrorDiv)
const markerRect = caretMarker.getBoundingClientRect()
const mirrorRect = mirrorDiv.getBoundingClientRect()
document.body.removeChild(mirrorDiv)
const leftOffset = markerRect.left - mirrorRect.left - textarea.scrollLeft
const topOffset = markerRect.top - mirrorRect.top - textarea.scrollTop
return {
left: textareaRect.left + leftOffset,
top: textareaRect.top + topOffset,
}
}
const caretPos = getCaretPos()
const { caretViewport, side } = useCaretViewport({ textareaRef, message, caretPos })
const caretViewport = getCaretViewport(textareaEl, caretPos, message)
if (!caretViewport) return null
// Decide preferred side based on available space
const margin = 8
const spaceAbove = caretViewport.top - margin
const spaceBelow = window.innerHeight - caretViewport.top - margin
const side: 'top' | 'bottom' = spaceBelow >= spaceAbove ? 'bottom' : 'top'
return (
<Popover open={true} onOpenChange={() => {}}>
<Popover
open={open}
onOpenChange={() => {
/* controlled by mentionMenu */
}}
>
<PopoverAnchor asChild>
<div
style={{
@@ -314,19 +393,401 @@ export function MentionMenu({
collisionPadding={6}
maxHeight={360}
className='pointer-events-auto'
style={{ width: '224px' }}
style={{
width: `224px`,
}}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onMouseDown={(e) => e.preventDefault()}
>
<PopoverBackButton />
<MentionMenuContent
mentionMenu={mentionMenu}
mentionData={mentionData}
message={message}
insertHandlers={insertHandlers}
onFolderNavChange={onFolderNavChange}
/>
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{openSubmenuFor ? (
// Submenu view - showing contents of a specific folder
<>
{openSubmenuFor === 'Chats' && (
<>
{mentionData.isLoadingPastChats ? (
<LoadingState />
) : mentionData.pastChats.length === 0 ? (
<EmptyState message='No past chats' />
) : (
mentionData.pastChats.map((chat, index) => (
<PopoverItem
key={chat.id}
onClick={() => insertPastChatMention(chat)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{chat.title || 'New Chat'}</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Workflows' && (
<>
{mentionData.isLoadingWorkflows ? (
<LoadingState />
) : mentionData.workflows.length === 0 ? (
<EmptyState message='No workflows' />
) : (
mentionData.workflows.map((wf, index) => (
<PopoverItem
key={wf.id}
onClick={() => insertWorkflowMention(wf)}
data-idx={index}
active={index === submenuActiveIndex}
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ backgroundColor: wf.color || '#3972F6' }}
/>
<span className='truncate'>{wf.name || 'Untitled Workflow'}</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Knowledge' && (
<>
{mentionData.isLoadingKnowledge ? (
<LoadingState />
) : mentionData.knowledgeBases.length === 0 ? (
<EmptyState message='No knowledge bases' />
) : (
mentionData.knowledgeBases.map((kb, index) => (
<PopoverItem
key={kb.id}
onClick={() => insertKnowledgeMention(kb)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{kb.name || 'Untitled'}</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Blocks' && (
<>
{mentionData.isLoadingBlocks ? (
<LoadingState />
) : mentionData.blocksList.length === 0 ? (
<EmptyState message='No blocks found' />
) : (
mentionData.blocksList.map((blk, index) => {
const Icon = blk.iconComponent
return (
<PopoverItem
key={blk.id}
onClick={() => insertBlockMention(blk)}
data-idx={index}
active={index === submenuActiveIndex}
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
<span className='truncate'>{blk.name || blk.id}</span>
</PopoverItem>
)
})
)}
</>
)}
{openSubmenuFor === 'Workflow Blocks' && (
<>
{mentionData.isLoadingWorkflowBlocks ? (
<LoadingState />
) : mentionData.workflowBlocks.length === 0 ? (
<EmptyState message='No blocks in this workflow' />
) : (
mentionData.workflowBlocks.map((blk, index) => {
const Icon = blk.iconComponent
return (
<PopoverItem
key={blk.id}
onClick={() => insertWorkflowBlockMention(blk)}
data-idx={index}
active={index === submenuActiveIndex}
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
<span className='truncate'>{blk.name || blk.id}</span>
</PopoverItem>
)
})
)}
</>
)}
{openSubmenuFor === 'Templates' && (
<>
{mentionData.isLoadingTemplates ? (
<LoadingState />
) : mentionData.templatesList.length === 0 ? (
<EmptyState message='No templates found' />
) : (
mentionData.templatesList.map((tpl, index) => (
<PopoverItem
key={tpl.id}
onClick={() => insertTemplateMention(tpl)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='flex-1 truncate'>{tpl.name}</span>
<span className='text-[10px] text-[var(--text-muted)]'>{tpl.stars}</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Logs' && (
<>
{mentionData.isLoadingLogs ? (
<LoadingState />
) : mentionData.logsList.length === 0 ? (
<EmptyState message='No executions found' />
) : (
mentionData.logsList.map((log, index) => (
<PopoverItem
key={log.id}
onClick={() => insertLogMention(log)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='min-w-0 flex-1 truncate'>{log.workflowName}</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatTimestamp(log.createdAt)}
</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='text-[10px] capitalize'>
{(log.trigger || 'manual').toLowerCase()}
</span>
</PopoverItem>
))
)}
</>
)}
</>
) : showAggregatedView ? (
// Aggregated filtered view
<>
{filteredAggregatedItems.length === 0 ? (
<EmptyState message='No results found' />
) : (
filteredAggregatedItems.map((item, index) => (
<PopoverItem
key={item.id}
onClick={() => handleAggregatedItemClick(item)}
data-idx={index}
active={index === submenuActiveIndex}
>
{item.icon}
<span className='flex-1 truncate'>{item.label}</span>
{item.category === 'logs' && (
<>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatTimestamp(item.data.createdAt)}
</span>
</>
)}
</PopoverItem>
))
)}
</>
) : (
// Folder navigation view
<>
<PopoverFolder
id='chats'
title='Chats'
onOpen={() => mentionData.ensurePastChatsLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 0}
data-idx={0}
>
{mentionData.isLoadingPastChats ? (
<LoadingState />
) : mentionData.pastChats.length === 0 ? (
<EmptyState message='No past chats' />
) : (
mentionData.pastChats.map((chat) => (
<PopoverItem key={chat.id} onClick={() => insertPastChatMention(chat)}>
<span className='truncate'>{chat.title || 'New Chat'}</span>
</PopoverItem>
))
)}
</PopoverFolder>
<PopoverFolder
id='workflows'
title='All workflows'
onOpen={() => mentionData.ensureWorkflowsLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 1}
data-idx={1}
>
{mentionData.isLoadingWorkflows ? (
<LoadingState />
) : mentionData.workflows.length === 0 ? (
<EmptyState message='No workflows' />
) : (
mentionData.workflows.map((wf) => (
<PopoverItem key={wf.id} onClick={() => insertWorkflowMention(wf)}>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ backgroundColor: wf.color || '#3972F6' }}
/>
<span className='truncate'>{wf.name || 'Untitled Workflow'}</span>
</PopoverItem>
))
)}
</PopoverFolder>
<PopoverFolder
id='knowledge'
title='Knowledge Bases'
onOpen={() => mentionData.ensureKnowledgeLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 2}
data-idx={2}
>
{mentionData.isLoadingKnowledge ? (
<LoadingState />
) : mentionData.knowledgeBases.length === 0 ? (
<EmptyState message='No knowledge bases' />
) : (
mentionData.knowledgeBases.map((kb) => (
<PopoverItem key={kb.id} onClick={() => insertKnowledgeMention(kb)}>
<span className='truncate'>{kb.name || 'Untitled'}</span>
</PopoverItem>
))
)}
</PopoverFolder>
<PopoverFolder
id='blocks'
title='Blocks'
onOpen={() => mentionData.ensureBlocksLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 3}
data-idx={3}
>
{mentionData.isLoadingBlocks ? (
<LoadingState />
) : mentionData.blocksList.length === 0 ? (
<EmptyState message='No blocks found' />
) : (
mentionData.blocksList.map((blk) => {
const Icon = blk.iconComponent
return (
<PopoverItem key={blk.id} onClick={() => insertBlockMention(blk)}>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
<span className='truncate'>{blk.name || blk.id}</span>
</PopoverItem>
)
})
)}
</PopoverFolder>
<PopoverFolder
id='workflow-blocks'
title='Workflow Blocks'
onOpen={() => mentionData.ensureWorkflowBlocksLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 4}
data-idx={4}
>
{mentionData.isLoadingWorkflowBlocks ? (
<LoadingState />
) : mentionData.workflowBlocks.length === 0 ? (
<EmptyState message='No blocks in this workflow' />
) : (
mentionData.workflowBlocks.map((blk) => {
const Icon = blk.iconComponent
return (
<PopoverItem key={blk.id} onClick={() => insertWorkflowBlockMention(blk)}>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
<span className='truncate'>{blk.name || blk.id}</span>
</PopoverItem>
)
})
)}
</PopoverFolder>
<PopoverFolder
id='templates'
title='Templates'
onOpen={() => mentionData.ensureTemplatesLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 5}
data-idx={5}
>
{mentionData.isLoadingTemplates ? (
<LoadingState />
) : mentionData.templatesList.length === 0 ? (
<EmptyState message='No templates found' />
) : (
mentionData.templatesList.map((tpl) => (
<PopoverItem key={tpl.id} onClick={() => insertTemplateMention(tpl)}>
<span className='flex-1 truncate'>{tpl.name}</span>
<span className='text-[10px] text-[var(--text-muted)]'>{tpl.stars}</span>
</PopoverItem>
))
)}
</PopoverFolder>
<PopoverFolder
id='logs'
title='Logs'
onOpen={() => mentionData.ensureLogsLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 6}
data-idx={6}
>
{mentionData.isLoadingLogs ? (
<LoadingState />
) : mentionData.logsList.length === 0 ? (
<EmptyState message='No executions found' />
) : (
mentionData.logsList.map((log) => (
<PopoverItem key={log.id} onClick={() => insertLogMention(log)}>
<span className='min-w-0 flex-1 truncate'>{log.workflowName}</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatTimestamp(log.createdAt)}
</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='text-[10px] capitalize'>
{(log.trigger || 'manual').toLowerCase()}
</span>
</PopoverItem>
))
)}
</PopoverFolder>
<PopoverItem
rootOnly
onClick={() => insertDocsMention()}
active={isInFolderNavigationMode && mentionActiveIndex === 7}
data-idx={7}
>
<span>Docs</span>
</PopoverItem>
</>
)}
</PopoverScrollArea>
</PopoverContent>
</Popover>
)

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useMemo } from 'react'
import { useMemo } from 'react'
import {
Popover,
PopoverAnchor,
@@ -9,167 +9,147 @@ import {
PopoverFolder,
PopoverItem,
PopoverScrollArea,
usePopoverContext,
} from '@/components/emcn'
import {
ALL_SLASH_COMMANDS,
MENU_STATE_TEXT_CLASSES,
TOP_LEVEL_COMMANDS,
WEB_COMMANDS,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import { useCaretViewport } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import type { useMentionMenu } from '../../hooks/use-mention-menu'
export interface SlashFolderNav {
isInFolder: boolean
openWebFolder: () => void
closeFolder: () => void
}
/**
* Top-level slash command options
*/
const TOP_LEVEL_COMMANDS = [
{ id: 'fast', label: 'fast' },
{ id: 'plan', label: 'plan' },
{ id: 'debug', label: 'debug' },
{ id: 'research', label: 'research' },
{ id: 'deploy', label: 'deploy' },
{ id: 'superagent', label: 'superagent' },
] as const
/**
* Web submenu commands
*/
const WEB_COMMANDS = [
{ id: 'search', label: 'search' },
{ id: 'read', label: 'read' },
{ id: 'scrape', label: 'scrape' },
{ id: 'crawl', label: 'crawl' },
] as const
/**
* All command labels for filtering
*/
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
interface SlashMenuProps {
mentionMenu: ReturnType<typeof useMentionMenu>
message: string
onSelectCommand: (command: string) => void
onFolderNavChange?: (nav: SlashFolderNav) => void
}
function SlashMenuContent({
mentionMenu,
message,
onSelectCommand,
onFolderNavChange,
}: SlashMenuProps) {
const { currentFolder, openFolder, closeFolder } = usePopoverContext()
/**
* SlashMenu component for slash command dropdown.
* Shows command options when user types '/'.
*
* @param props - Component props
* @returns Rendered slash menu
*/
export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuProps) {
const {
mentionMenuRef,
menuListRef,
getActiveSlashQueryAtPosition,
getCaretPos,
submenuActiveIndex,
mentionActiveIndex,
setSubmenuActiveIndex,
openSubmenuFor,
setOpenSubmenuFor,
} = mentionMenu
const caretPos = getCaretPos()
/**
* Get the current query string after /
*/
const currentQuery = useMemo(() => {
const caretPos = getCaretPos()
const active = getActiveSlashQueryAtPosition(caretPos, message)
return active?.query.trim().toLowerCase() || ''
}, [message, caretPos, getActiveSlashQueryAtPosition])
}, [message, getCaretPos, getActiveSlashQueryAtPosition])
/**
* Filter commands based on query (search across all commands when there's a query)
*/
const filteredCommands = useMemo(() => {
if (!currentQuery) return null
return ALL_SLASH_COMMANDS.filter(
(cmd) =>
cmd.id.toLowerCase().includes(currentQuery) ||
cmd.label.toLowerCase().includes(currentQuery)
)
if (!currentQuery) return null // Show folder view when no query
return ALL_COMMANDS.filter((cmd) => cmd.label.toLowerCase().includes(currentQuery))
}, [currentQuery])
// Show aggregated view when there's a query
const showAggregatedView = currentQuery.length > 0
const isInFolder = currentFolder !== null
const isInFolderNavigationMode = !isInFolder && !showAggregatedView
useEffect(() => {
if (onFolderNavChange) {
onFolderNavChange({
isInFolder,
openWebFolder: () => {
openFolder('web', 'Web')
setSubmenuActiveIndex(0)
},
closeFolder: () => {
closeFolder()
setSubmenuActiveIndex(0)
},
})
// Compute caret viewport position via mirror technique for precise anchoring
const textareaEl = mentionMenu.textareaRef.current
if (!textareaEl) return null
const getCaretViewport = (textarea: HTMLTextAreaElement, caretPosition: number, text: string) => {
const textareaRect = textarea.getBoundingClientRect()
const style = window.getComputedStyle(textarea)
const mirrorDiv = document.createElement('div')
mirrorDiv.style.position = 'absolute'
mirrorDiv.style.visibility = 'hidden'
mirrorDiv.style.whiteSpace = 'pre-wrap'
mirrorDiv.style.wordWrap = 'break-word'
mirrorDiv.style.font = style.font
mirrorDiv.style.padding = style.padding
mirrorDiv.style.border = style.border
mirrorDiv.style.width = style.width
mirrorDiv.style.lineHeight = style.lineHeight
mirrorDiv.style.boxSizing = style.boxSizing
mirrorDiv.style.letterSpacing = style.letterSpacing
mirrorDiv.style.textTransform = style.textTransform
mirrorDiv.style.textIndent = style.textIndent
mirrorDiv.style.textAlign = style.textAlign
mirrorDiv.textContent = text.substring(0, caretPosition)
const caretMarker = document.createElement('span')
caretMarker.style.display = 'inline-block'
caretMarker.style.width = '0px'
caretMarker.style.padding = '0'
caretMarker.style.border = '0'
mirrorDiv.appendChild(caretMarker)
document.body.appendChild(mirrorDiv)
const markerRect = caretMarker.getBoundingClientRect()
const mirrorRect = mirrorDiv.getBoundingClientRect()
document.body.removeChild(mirrorDiv)
const leftOffset = markerRect.left - mirrorRect.left - textarea.scrollLeft
const topOffset = markerRect.top - mirrorRect.top - textarea.scrollTop
return {
left: textareaRect.left + leftOffset,
top: textareaRect.top + topOffset,
}
}, [onFolderNavChange, isInFolder, openFolder, closeFolder, setSubmenuActiveIndex])
return (
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{isInFolder ? (
<>
{WEB_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</>
) : showAggregatedView ? (
<>
{filteredCommands && filteredCommands.length === 0 ? (
<div className={MENU_STATE_TEXT_CLASSES}>No commands found</div>
) : (
filteredCommands?.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))
)}
</>
) : (
<>
{TOP_LEVEL_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={isInFolderNavigationMode && index === mentionActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
<PopoverFolder
id='web'
title='Web'
onOpen={() => setSubmenuActiveIndex(0)}
active={isInFolderNavigationMode && mentionActiveIndex === TOP_LEVEL_COMMANDS.length}
data-idx={TOP_LEVEL_COMMANDS.length}
>
{WEB_COMMANDS.map((cmd) => (
<PopoverItem key={cmd.id} onClick={() => onSelectCommand(cmd.id)}>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</PopoverFolder>
</>
)}
</PopoverScrollArea>
)
}
export function SlashMenu({
mentionMenu,
message,
onSelectCommand,
onFolderNavChange,
}: SlashMenuProps) {
const { mentionMenuRef, textareaRef, getCaretPos } = mentionMenu
}
const caretPos = getCaretPos()
const caretViewport = getCaretViewport(textareaEl, caretPos, message)
const { caretViewport, side } = useCaretViewport({
textareaRef,
message,
caretPos,
})
// Decide preferred side based on available space
const margin = 8
const spaceAbove = caretViewport.top - margin
const spaceBelow = window.innerHeight - caretViewport.top - margin
const side: 'top' | 'bottom' = spaceBelow >= spaceAbove ? 'bottom' : 'top'
if (!caretViewport) return null
// Check if we're in folder navigation mode (no query, not in submenu)
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
return (
<Popover open={true} onOpenChange={() => {}}>
<Popover
open={true}
onOpenChange={() => {
/* controlled externally */
}}
>
<PopoverAnchor asChild>
<div
style={{
@@ -189,18 +169,80 @@ export function SlashMenu({
collisionPadding={6}
maxHeight={360}
className='pointer-events-auto'
style={{ width: '180px' }}
style={{
width: `180px`,
}}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onMouseDown={(e) => e.preventDefault()}
>
<PopoverBackButton />
<SlashMenuContent
mentionMenu={mentionMenu}
message={message}
onSelectCommand={onSelectCommand}
onFolderNavChange={onFolderNavChange}
/>
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{openSubmenuFor === 'Web' ? (
// Web submenu view
<>
{WEB_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.label)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate capitalize'>{cmd.label}</span>
</PopoverItem>
))}
</>
) : showAggregatedView ? (
// Aggregated filtered view
<>
{filteredCommands && filteredCommands.length === 0 ? (
<div className='px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'>
No commands found
</div>
) : (
filteredCommands?.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.label)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate capitalize'>{cmd.label}</span>
</PopoverItem>
))
)}
</>
) : (
// Folder navigation view
<>
{TOP_LEVEL_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.label)}
data-idx={index}
active={isInFolderNavigationMode && index === mentionActiveIndex}
>
<span className='truncate capitalize'>{cmd.label}</span>
</PopoverItem>
))}
<PopoverFolder
id='web'
title='Web'
onOpen={() => setOpenSubmenuFor('Web')}
active={
isInFolderNavigationMode && mentionActiveIndex === TOP_LEVEL_COMMANDS.length
}
data-idx={TOP_LEVEL_COMMANDS.length}
>
{WEB_COMMANDS.map((cmd) => (
<PopoverItem key={cmd.id} onClick={() => onSelectCommand(cmd.label)}>
<span className='truncate capitalize'>{cmd.label}</span>
</PopoverItem>
))}
</PopoverFolder>
</>
)}
</PopoverScrollArea>
</PopoverContent>
</Popover>
)

View File

@@ -1,245 +1,42 @@
import type { ChatContext } from '@/stores/panel'
/**
* Constants for user input component
*/
/**
* Mention folder types
* Mention menu options in order (matches visual render order)
*/
export type MentionFolderId =
| 'chats'
| 'workflows'
| 'knowledge'
| 'blocks'
| 'workflow-blocks'
| 'templates'
| 'logs'
/**
* Menu item category types for mention menu (includes folders + docs item)
*/
export type MentionCategory = MentionFolderId | 'docs'
/**
* Configuration interface for folder types
*/
export interface FolderConfig<TItem = any> {
/** Display title in menu */
title: string
/** Data source key in useMentionData return */
dataKey: string
/** Loading state key in useMentionData return */
loadingKey: string
/** Ensure loaded function key in useMentionData return (optional - some folders auto-load) */
ensureLoadedKey?: string
/** Extract label from an item */
getLabel: (item: TItem) => string
/** Extract unique ID from an item */
getId: (item: TItem) => string
/** Empty state message */
emptyMessage: string
/** No match message (when filtering) */
noMatchMessage: string
/** Filter function for matching query */
filterFn: (item: TItem, query: string) => boolean
/** Build the ChatContext object from an item */
buildContext: (item: TItem, workflowId?: string | null) => ChatContext
/** Whether to use insertAtCursor fallback when replaceActiveMentionWith fails */
useInsertFallback?: boolean
}
/**
* Configuration for all folder types in the mention menu
*/
export const FOLDER_CONFIGS: Record<MentionFolderId, FolderConfig> = {
chats: {
title: 'Chats',
dataKey: 'pastChats',
loadingKey: 'isLoadingPastChats',
ensureLoadedKey: 'ensurePastChatsLoaded',
getLabel: (item) => item.title || 'New Chat',
getId: (item) => item.id,
emptyMessage: 'No past chats',
noMatchMessage: 'No matching chats',
filterFn: (item, q) => (item.title || 'New Chat').toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'past_chat',
chatId: item.id,
label: item.title || 'New Chat',
}),
useInsertFallback: false,
},
workflows: {
title: 'All workflows',
dataKey: 'workflows',
loadingKey: 'isLoadingWorkflows',
// No ensureLoadedKey - workflows auto-load from registry store
getLabel: (item) => item.name || 'Untitled Workflow',
getId: (item) => item.id,
emptyMessage: 'No workflows',
noMatchMessage: 'No matching workflows',
filterFn: (item, q) => (item.name || 'Untitled Workflow').toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'workflow',
workflowId: item.id,
label: item.name || 'Untitled Workflow',
}),
useInsertFallback: true,
},
knowledge: {
title: 'Knowledge Bases',
dataKey: 'knowledgeBases',
loadingKey: 'isLoadingKnowledge',
ensureLoadedKey: 'ensureKnowledgeLoaded',
getLabel: (item) => item.name || 'Untitled',
getId: (item) => item.id,
emptyMessage: 'No knowledge bases',
noMatchMessage: 'No matching knowledge bases',
filterFn: (item, q) => (item.name || 'Untitled').toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'knowledge',
knowledgeId: item.id,
label: item.name || 'Untitled',
}),
useInsertFallback: false,
},
blocks: {
title: 'Blocks',
dataKey: 'blocksList',
loadingKey: 'isLoadingBlocks',
ensureLoadedKey: 'ensureBlocksLoaded',
getLabel: (item) => item.name || item.id,
getId: (item) => item.id,
emptyMessage: 'No blocks found',
noMatchMessage: 'No matching blocks',
filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'blocks',
blockIds: [item.id],
label: item.name || item.id,
}),
useInsertFallback: false,
},
'workflow-blocks': {
title: 'Workflow Blocks',
dataKey: 'workflowBlocks',
loadingKey: 'isLoadingWorkflowBlocks',
// No ensureLoadedKey - workflow blocks auto-sync from store
getLabel: (item) => item.name || item.id,
getId: (item) => item.id,
emptyMessage: 'No blocks in this workflow',
noMatchMessage: 'No matching blocks',
filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q),
buildContext: (item, workflowId) => ({
kind: 'workflow_block',
workflowId: workflowId || '',
blockId: item.id,
label: item.name || item.id,
}),
useInsertFallback: true,
},
templates: {
title: 'Templates',
dataKey: 'templatesList',
loadingKey: 'isLoadingTemplates',
ensureLoadedKey: 'ensureTemplatesLoaded',
getLabel: (item) => item.name || 'Untitled Template',
getId: (item) => item.id,
emptyMessage: 'No templates found',
noMatchMessage: 'No matching templates',
filterFn: (item, q) => (item.name || 'Untitled Template').toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'templates',
templateId: item.id,
label: item.name || 'Untitled Template',
}),
useInsertFallback: false,
},
logs: {
title: 'Logs',
dataKey: 'logsList',
loadingKey: 'isLoadingLogs',
ensureLoadedKey: 'ensureLogsLoaded',
getLabel: (item) => item.workflowName,
getId: (item) => item.id,
emptyMessage: 'No executions found',
noMatchMessage: 'No matching executions',
filterFn: (item, q) =>
[item.workflowName, item.trigger || ''].join(' ').toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'logs',
executionId: item.executionId || item.id,
label: item.workflowName,
}),
useInsertFallback: false,
},
}
/**
* Order of folders in the mention menu
*/
export const FOLDER_ORDER: MentionFolderId[] = [
'chats',
'workflows',
'knowledge',
'blocks',
'workflow-blocks',
'templates',
'logs',
]
/**
* Docs item configuration (special case - not a folder)
*/
export const DOCS_CONFIG = {
getLabel: () => 'Docs',
buildContext: (): ChatContext => ({ kind: 'docs', label: 'Docs' }),
} as const
/**
* Total number of items in root menu (folders + docs)
*/
export const ROOT_MENU_ITEM_COUNT = FOLDER_ORDER.length + 1
/**
* Slash command configuration
*/
export interface SlashCommand {
id: string
label: string
}
export const TOP_LEVEL_COMMANDS: readonly SlashCommand[] = [
{ id: 'fast', label: 'Fast' },
{ id: 'research', label: 'Research' },
{ id: 'superagent', label: 'Actions' },
export const MENTION_OPTIONS = [
'Chats',
'Workflows',
'Knowledge',
'Blocks',
'Workflow Blocks',
'Templates',
'Logs',
'Docs',
] as const
export const WEB_COMMANDS: readonly SlashCommand[] = [
{ id: 'search', label: 'Search' },
{ id: 'read', label: 'Read' },
{ id: 'scrape', label: 'Scrape' },
{ id: 'crawl', label: 'Crawl' },
] as const
export const ALL_SLASH_COMMANDS: readonly SlashCommand[] = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
export const ALL_COMMAND_IDS = ALL_SLASH_COMMANDS.map((cmd) => cmd.id)
/**
* Get display label for a command ID
*/
export function getCommandDisplayLabel(commandId: string): string {
const command = ALL_SLASH_COMMANDS.find((cmd) => cmd.id === commandId)
return command?.label || commandId.charAt(0).toUpperCase() + commandId.slice(1)
}
/**
* Model configuration options
*/
export const MODEL_OPTIONS = [
{ value: 'claude-4.5-opus', label: 'Claude 4.5 Opus' },
{ value: 'claude-4.5-sonnet', label: 'Claude 4.5 Sonnet' },
// { value: 'claude-4-sonnet', label: 'Claude 4 Sonnet' },
{ value: 'claude-4.5-haiku', label: 'Claude 4.5 Haiku' },
// { value: 'claude-4.1-opus', label: 'Claude 4.1 Opus' },
{ value: 'gpt-5.1-codex', label: 'GPT 5.1 Codex' },
// { value: 'gpt-5-codex', label: 'GPT 5 Codex' },
{ value: 'gpt-5.1-medium', label: 'GPT 5.1 Medium' },
// { value: 'gpt-5-fast', label: 'GPT 5 Fast' },
// { value: 'gpt-5', label: 'GPT 5' },
// { value: 'gpt-5.1-fast', label: 'GPT 5.1 Fast' },
// { value: 'gpt-5.1', label: 'GPT 5.1' },
// { value: 'gpt-5.1-high', label: 'GPT 5.1 High' },
// { value: 'gpt-5-high', label: 'GPT 5 High' },
// { value: 'gpt-4o', label: 'GPT 4o' },
// { value: 'gpt-4.1', label: 'GPT 4.1' },
// { value: 'o3', label: 'o3' },
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
] as const
@@ -252,18 +49,3 @@ export const NEAR_TOP_THRESHOLD = 300
* Scroll tolerance for mention menu positioning (in pixels)
*/
export const SCROLL_TOLERANCE = 8
/**
* Shared CSS classes for menu state text (loading, empty states)
*/
export const MENU_STATE_TEXT_CLASSES = 'px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'
/**
* Calculates the next index for circular navigation (wraps around at bounds)
*/
export function getNextIndex(current: number, direction: 'up' | 'down', maxIndex: number): number {
if (direction === 'down') {
return current >= maxIndex ? 0 : current + 1
}
return current <= 0 ? maxIndex : current - 1
}

View File

@@ -1,4 +1,3 @@
export { useCaretViewport } from './use-caret-viewport'
export { useContextManagement } from './use-context-management'
export { useFileAttachments } from './use-file-attachments'
export { useMentionData } from './use-mention-data'

View File

@@ -1,77 +0,0 @@
import { useMemo } from 'react'
interface CaretViewportPosition {
left: number
top: number
}
interface UseCaretViewportResult {
caretViewport: CaretViewportPosition | null
side: 'top' | 'bottom'
}
interface UseCaretViewportProps {
textareaRef: React.RefObject<HTMLTextAreaElement | null>
message: string
caretPos: number
}
/**
* Calculates the viewport position of the caret in a textarea using the mirror div technique.
* This hook memoizes the calculation to prevent unnecessary DOM manipulation on every render.
*/
export function useCaretViewport({
textareaRef,
message,
caretPos,
}: UseCaretViewportProps): UseCaretViewportResult {
return useMemo(() => {
const textareaEl = textareaRef.current
if (!textareaEl) {
return { caretViewport: null, side: 'bottom' as const }
}
const textareaRect = textareaEl.getBoundingClientRect()
const style = window.getComputedStyle(textareaEl)
const mirrorDiv = document.createElement('div')
mirrorDiv.style.position = 'absolute'
mirrorDiv.style.visibility = 'hidden'
mirrorDiv.style.whiteSpace = 'pre-wrap'
mirrorDiv.style.overflowWrap = 'break-word'
mirrorDiv.style.font = style.font
mirrorDiv.style.padding = style.padding
mirrorDiv.style.border = style.border
mirrorDiv.style.width = style.width
mirrorDiv.style.lineHeight = style.lineHeight
mirrorDiv.style.boxSizing = style.boxSizing
mirrorDiv.style.letterSpacing = style.letterSpacing
mirrorDiv.style.textTransform = style.textTransform
mirrorDiv.style.textIndent = style.textIndent
mirrorDiv.style.textAlign = style.textAlign
mirrorDiv.textContent = message.substring(0, caretPos)
const caretMarker = document.createElement('span')
caretMarker.style.display = 'inline-block'
caretMarker.style.width = '0px'
caretMarker.style.padding = '0'
caretMarker.style.border = '0'
mirrorDiv.appendChild(caretMarker)
document.body.appendChild(mirrorDiv)
const markerRect = caretMarker.getBoundingClientRect()
const mirrorRect = mirrorDiv.getBoundingClientRect()
document.body.removeChild(mirrorDiv)
const caretViewport = {
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textareaEl.scrollLeft,
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textareaEl.scrollTop,
}
const margin = 8
const spaceBelow = window.innerHeight - caretViewport.top - margin
const side: 'top' | 'bottom' = spaceBelow >= caretViewport.top - margin ? 'bottom' : 'top'
return { caretViewport, side }
}, [textareaRef, message, caretPos])
}

View File

@@ -1,8 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
filterOutContext,
isContextAlreadySelected,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { ChatContext } from '@/stores/panel'
interface UseContextManagementProps {
@@ -39,7 +35,53 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
*/
const addContext = useCallback((context: ChatContext) => {
setSelectedContexts((prev) => {
if (isContextAlreadySelected(context, prev)) return prev
// CRITICAL: Check label collision FIRST
// The token system uses @label format, so we cannot have duplicate labels
// regardless of kind or ID differences
const exists = prev.some((c) => {
// Primary check: label collision
// This prevents duplicate @Label tokens which would break the overlay
if (c.label && context.label && c.label === context.label) {
return true
}
// Secondary check: exact duplicate by ID fields based on kind
// This prevents the same entity from being added twice even with different labels
if (c.kind === context.kind) {
if (c.kind === 'past_chat' && 'chatId' in context && 'chatId' in c) {
return c.chatId === (context as any).chatId
}
if (c.kind === 'workflow' && 'workflowId' in context && 'workflowId' in c) {
return c.workflowId === (context as any).workflowId
}
if (c.kind === 'blocks' && 'blockId' in context && 'blockId' in c) {
return c.blockId === (context as any).blockId
}
if (c.kind === 'workflow_block' && 'blockId' in context && 'blockId' in c) {
return (
c.workflowId === (context as any).workflowId && c.blockId === (context as any).blockId
)
}
if (c.kind === 'knowledge' && 'knowledgeId' in context && 'knowledgeId' in c) {
return c.knowledgeId === (context as any).knowledgeId
}
if (c.kind === 'templates' && 'templateId' in context && 'templateId' in c) {
return c.templateId === (context as any).templateId
}
if (c.kind === 'logs' && 'executionId' in context && 'executionId' in c) {
return c.executionId === (context as any).executionId
}
if (c.kind === 'docs') {
return true // Only one docs context allowed
}
if (c.kind === 'slash_command' && 'command' in context && 'command' in c) {
return c.command === (context as any).command
}
}
return false
})
if (exists) return prev
return [...prev, context]
})
}, [])
@@ -50,7 +92,38 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
* @param contextToRemove - Context to remove
*/
const removeContext = useCallback((contextToRemove: ChatContext) => {
setSelectedContexts((prev) => filterOutContext(prev, contextToRemove))
setSelectedContexts((prev) =>
prev.filter((c) => {
// Match by kind and specific ID fields
if (c.kind !== contextToRemove.kind) return true
switch (c.kind) {
case 'past_chat':
return (c as any).chatId !== (contextToRemove as any).chatId
case 'workflow':
return (c as any).workflowId !== (contextToRemove as any).workflowId
case 'blocks':
return (c as any).blockId !== (contextToRemove as any).blockId
case 'workflow_block':
return (
(c as any).workflowId !== (contextToRemove as any).workflowId ||
(c as any).blockId !== (contextToRemove as any).blockId
)
case 'knowledge':
return (c as any).knowledgeId !== (contextToRemove as any).knowledgeId
case 'templates':
return (c as any).templateId !== (contextToRemove as any).templateId
case 'logs':
return (c as any).executionId !== (contextToRemove as any).executionId
case 'docs':
return false // Remove docs (only one docs context)
case 'slash_command':
return (c as any).command !== (contextToRemove as any).command
default:
return c.label !== contextToRemove.label
}
})
)
}, [])
/**

View File

@@ -83,36 +83,6 @@ interface UseMentionDataProps {
workspaceId: string
}
/**
* Return type for useMentionData hook
*/
export interface MentionDataReturn {
// Data arrays
pastChats: PastChat[]
workflows: WorkflowItem[]
knowledgeBases: KnowledgeItem[]
blocksList: BlockItem[]
workflowBlocks: WorkflowBlockItem[]
templatesList: TemplateItem[]
logsList: LogItem[]
// Loading states
isLoadingPastChats: boolean
isLoadingWorkflows: boolean
isLoadingKnowledge: boolean
isLoadingBlocks: boolean
isLoadingWorkflowBlocks: boolean
isLoadingTemplates: boolean
isLoadingLogs: boolean
// Ensure loaded functions
ensurePastChatsLoaded: () => Promise<void>
ensureKnowledgeLoaded: () => Promise<void>
ensureBlocksLoaded: () => Promise<void>
ensureTemplatesLoaded: () => Promise<void>
ensureLogsLoaded: () => Promise<void>
}
/**
* Custom hook to fetch and manage data for mention suggestions
* Loads data from APIs for chats, workflows, knowledge bases, blocks, templates, and logs
@@ -120,7 +90,7 @@ export interface MentionDataReturn {
* @param props - Configuration including workflow and workspace IDs
* @returns Mention data state and loading operations
*/
export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
export function useMentionData(props: UseMentionDataProps) {
const { workflowId, workspaceId } = props
const { config, isBlockAllowed } = usePermissionConfig()
@@ -134,6 +104,7 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
const [blocksList, setBlocksList] = useState<BlockItem[]>([])
const [isLoadingBlocks, setIsLoadingBlocks] = useState(false)
// Reset blocks list when permission config changes
useEffect(() => {
setBlocksList([])
}, [config.allowedIntegrations])
@@ -147,10 +118,12 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
const [workflowBlocks, setWorkflowBlocks] = useState<WorkflowBlockItem[]>([])
const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false)
// Only subscribe to block keys to avoid re-rendering on position updates
const blockKeys = useWorkflowStore(
useShallow(useCallback((state) => Object.keys(state.blocks), []))
)
// Use workflow registry as source of truth for workflows
const registryWorkflows = useWorkflowRegistry((state) => state.workflows)
const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
const isLoadingWorkflows =
@@ -158,6 +131,7 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
hydrationPhase === 'metadata-loading' ||
hydrationPhase === 'state-loading'
// Convert registry workflows to mention format, filtered by workspace and sorted
const workflows: WorkflowItem[] = Object.values(registryWorkflows)
.filter((w) => w.workspaceId === workspaceId)
.sort((a, b) => {
@@ -245,6 +219,14 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
}
}, [isLoadingPastChats, pastChats.length, workflowId])
/**
* Ensures workflows are loaded (now using registry store)
*/
const ensureWorkflowsLoaded = useCallback(() => {
// Workflows are now automatically loaded from the registry store
// No manual fetching needed
}, [])
/**
* Ensures knowledge bases are loaded
*/
@@ -366,6 +348,18 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
}
}, [isLoadingLogs, logsList.length, workspaceId])
/**
* Ensures workflow blocks are loaded (synced from store)
*/
const ensureWorkflowBlocksLoaded = useCallback(async () => {
if (!workflowId) return
logger.debug('ensureWorkflowBlocksLoaded called', {
workflowId,
storeBlocksCount: blockKeys.length,
workflowBlocksCount: workflowBlocks.length,
})
}, [workflowId, blockKeys.length, workflowBlocks.length])
return {
// State
pastChats,
@@ -385,9 +379,11 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
// Operations
ensurePastChatsLoaded,
ensureWorkflowsLoaded,
ensureKnowledgeLoaded,
ensureBlocksLoaded,
ensureTemplatesLoaded,
ensureLogsLoaded,
ensureWorkflowBlocksLoaded,
}
}

View File

@@ -1,12 +1,5 @@
import { useCallback, useMemo } from 'react'
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import {
DOCS_CONFIG,
FOLDER_CONFIGS,
type FolderConfig,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
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 { isContextAlreadySelected } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { ChatContext } from '@/stores/panel'
interface UseMentionInsertHandlersProps {
@@ -18,12 +11,12 @@ interface UseMentionInsertHandlersProps {
selectedContexts: ChatContext[]
/** Callback to update selected contexts */
onContextAdd: (context: ChatContext) => void
/** Folder navigation state exposed from MentionMenu via callback */
mentionFolderNav?: MentionFolderNav | null
}
/**
* Custom hook to provide insert handlers for different mention types.
* Consolidates the logic for inserting mentions and updating selected contexts.
* Prevents duplicate mentions from being inserted.
*
* @param props - Configuration object
* @returns Insert handler functions for each mention type
@@ -33,7 +26,6 @@ export function useMentionInsertHandlers({
workflowId,
selectedContexts,
onContextAdd,
mentionFolderNav,
}: UseMentionInsertHandlersProps) {
const {
replaceActiveMentionWith,
@@ -44,94 +36,342 @@ export function useMentionInsertHandlers({
} = mentionMenu
/**
* Closes all menus and resets state
* Checks if a context already exists in selected contexts
* CRITICAL: Prioritizes label checking to prevent token system breakage
*
* @param context - Context to check
* @returns True if context already exists or label is already used
*/
const closeMenus = useCallback(() => {
setShowMentionMenu(false)
if (mentionFolderNav?.isInFolder) {
mentionFolderNav.closeFolder()
}
setOpenSubmenuFor(null)
}, [setShowMentionMenu, setOpenSubmenuFor, mentionFolderNav])
const createInsertHandler = useCallback(
<TItem>(config: FolderConfig<TItem>) => {
return (item: TItem) => {
const label = config.getLabel(item)
const context = config.buildContext(item, workflowId)
if (isContextAlreadySelected(context, selectedContexts)) {
resetActiveMentionQuery()
closeMenus()
return
const isContextAlreadySelected = useCallback(
(context: ChatContext): boolean => {
return selectedContexts.some((c) => {
// CRITICAL: Check label collision FIRST
// The token system uses @label format, so we cannot have duplicate labels
// regardless of kind or ID differences
if (c.label && context.label && c.label === context.label) {
return true
}
if (config.useInsertFallback) {
if (!replaceActiveMentionWith(label)) {
insertAtCursor(` @${label} `)
// Secondary check: exact duplicate by ID fields
if (c.kind === context.kind) {
if (c.kind === 'past_chat' && 'chatId' in context && 'chatId' in c) {
return c.chatId === (context as any).chatId
}
if (c.kind === 'workflow' && 'workflowId' in context && 'workflowId' in c) {
return c.workflowId === (context as any).workflowId
}
if (c.kind === 'blocks' && 'blockId' in context && 'blockId' in c) {
return c.blockId === (context as any).blockId
}
if (c.kind === 'workflow_block' && 'blockId' in context && 'blockId' in c) {
return (
c.workflowId === (context as any).workflowId && c.blockId === (context as any).blockId
)
}
if (c.kind === 'knowledge' && 'knowledgeId' in context && 'knowledgeId' in c) {
return c.knowledgeId === (context as any).knowledgeId
}
if (c.kind === 'templates' && 'templateId' in context && 'templateId' in c) {
return c.templateId === (context as any).templateId
}
if (c.kind === 'logs' && 'executionId' in context && 'executionId' in c) {
return c.executionId === (context as any).executionId
}
if (c.kind === 'docs') {
return true
}
} else {
replaceActiveMentionWith(label)
}
onContextAdd(context)
closeMenus()
return false
})
},
[selectedContexts]
)
/**
* Inserts a past chat mention
*
* @param chat - Chat object to mention
*/
const insertPastChatMention = useCallback(
(chat: { id: string; title: string | null }) => {
const label = chat.title || 'New Chat'
const context = { kind: 'past_chat', chatId: chat.id, label } as ChatContext
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text (e.g., "@Unti") before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
replaceActiveMentionWith(label)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
workflowId,
selectedContexts,
replaceActiveMentionWith,
insertAtCursor,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
closeMenus,
]
)
/**
* Special handler for Docs (no item parameter, uses DOCS_CONFIG)
* Inserts a workflow mention
*
* @param wf - Workflow object to mention
*/
const insertWorkflowMention = useCallback(
(wf: { id: string; name: string }) => {
const label = wf.name || 'Untitled Workflow'
const context = { kind: 'workflow', workflowId: wf.id, label } as ChatContext
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
if (!replaceActiveMentionWith(label)) insertAtCursor(` @${label} `)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
insertAtCursor,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a knowledge base mention
*
* @param kb - Knowledge base object to mention
*/
const insertKnowledgeMention = useCallback(
(kb: { id: string; name: string }) => {
const label = kb.name || 'Untitled'
const context = { kind: 'knowledge', knowledgeId: kb.id, label } as any
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
replaceActiveMentionWith(label)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a block mention
*
* @param blk - Block object to mention
*/
const insertBlockMention = useCallback(
(blk: { id: string; name: string }) => {
const label = blk.name || blk.id
const context = { kind: 'blocks', blockId: blk.id, label } as any
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
replaceActiveMentionWith(label)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a workflow block mention
*
* @param blk - Workflow block object to mention
*/
const insertWorkflowBlockMention = useCallback(
(blk: { id: string; name: string }) => {
const label = blk.name
const context = {
kind: 'workflow_block',
workflowId: workflowId as string,
blockId: blk.id,
label,
} as any
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
if (!replaceActiveMentionWith(label)) insertAtCursor(` @${label} `)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
insertAtCursor,
workflowId,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a template mention
*
* @param tpl - Template object to mention
*/
const insertTemplateMention = useCallback(
(tpl: { id: string; name: string }) => {
const label = tpl.name || 'Untitled Template'
const context = { kind: 'templates', templateId: tpl.id, label } as any
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
replaceActiveMentionWith(label)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a log mention
*
* @param log - Log object to mention
*/
const insertLogMention = useCallback(
(log: { id: string; executionId?: string; workflowName: string }) => {
const label = log.workflowName
const context = { kind: 'logs' as const, executionId: log.executionId, label }
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
replaceActiveMentionWith(label)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a docs mention
*/
const insertDocsMention = useCallback(() => {
const label = DOCS_CONFIG.getLabel()
const context = DOCS_CONFIG.buildContext()
const label = 'Docs'
const context = { kind: 'docs', label } as any
// Prevent duplicate insertion
if (isContextAlreadySelected(context, selectedContexts)) {
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
closeMenus()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
// Docs uses fallback insertion
if (!replaceActiveMentionWith(label)) {
insertAtCursor(` @${label} `)
}
if (!replaceActiveMentionWith(label)) insertAtCursor(` @${label} `)
onContextAdd(context)
closeMenus()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
}, [
selectedContexts,
replaceActiveMentionWith,
insertAtCursor,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
closeMenus,
])
const handlers = useMemo(
() => ({
insertPastChatMention: createInsertHandler(FOLDER_CONFIGS.chats),
insertWorkflowMention: createInsertHandler(FOLDER_CONFIGS.workflows),
insertKnowledgeMention: createInsertHandler(FOLDER_CONFIGS.knowledge),
insertBlockMention: createInsertHandler(FOLDER_CONFIGS.blocks),
insertWorkflowBlockMention: createInsertHandler(FOLDER_CONFIGS['workflow-blocks']),
insertTemplateMention: createInsertHandler(FOLDER_CONFIGS.templates),
insertLogMention: createInsertHandler(FOLDER_CONFIGS.logs),
insertDocsMention,
}),
[createInsertHandler, insertDocsMention]
)
return handlers
return {
insertPastChatMention,
insertWorkflowMention,
insertKnowledgeMention,
insertBlockMention,
insertWorkflowBlockMention,
insertTemplateMention,
insertLogMention,
insertDocsMention,
}
}

View File

@@ -1,19 +1,56 @@
import { type KeyboardEvent, useCallback, useMemo } from 'react'
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import {
FOLDER_CONFIGS,
FOLDER_ORDER,
type MentionFolderId,
ROOT_MENU_ITEM_COUNT,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import type {
useMentionData,
useMentionMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import {
getFolderData as getFolderDataUtil,
getFolderEnsureLoaded as getFolderEnsureLoadedUtil,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import { type KeyboardEvent, useCallback } from 'react'
import type { useMentionData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
import type { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu'
import { MENTION_OPTIONS } from '../constants'
/**
* Chat item for mention insertion
*/
interface ChatItem {
id: string
title: string | null
}
/**
* Workflow item for mention insertion
*/
interface WorkflowItem {
id: string
name: string
}
/**
* Knowledge base item for mention insertion
*/
interface KnowledgeItem {
id: string
name: string
}
/**
* Block item for mention insertion
*/
interface BlockItem {
id: string
name: string
}
/**
* Template item for mention insertion
*/
interface TemplateItem {
id: string
name: string
}
/**
* Log item for mention insertion
*/
interface LogItem {
id: string
executionId?: string
workflowName: string
}
interface UseMentionKeyboardProps {
/** Mention menu hook instance */
@@ -22,34 +59,37 @@ interface UseMentionKeyboardProps {
mentionData: ReturnType<typeof useMentionData>
/** Callback to insert specific mention types */
insertHandlers: {
insertPastChatMention: (chat: any) => void
insertWorkflowMention: (wf: any) => void
insertKnowledgeMention: (kb: any) => void
insertBlockMention: (blk: any) => void
insertWorkflowBlockMention: (blk: any) => void
insertTemplateMention: (tpl: any) => void
insertLogMention: (log: any) => void
insertPastChatMention: (chat: ChatItem) => void
insertWorkflowMention: (wf: WorkflowItem) => void
insertKnowledgeMention: (kb: KnowledgeItem) => void
insertBlockMention: (blk: BlockItem) => void
insertWorkflowBlockMention: (blk: BlockItem) => void
insertTemplateMention: (tpl: TemplateItem) => void
insertLogMention: (log: LogItem) => void
insertDocsMention: () => void
}
/** Folder navigation state exposed from MentionMenu via callback */
mentionFolderNav: MentionFolderNav | null
}
/**
* Custom hook to handle keyboard navigation in the mention menu.
* Manages Arrow Up/Down/Left/Right and Enter key navigation through menus and submenus.
*
* @param props - Configuration object
* @returns Keyboard handler for mention menu
*/
export function useMentionKeyboard({
mentionMenu,
mentionData,
insertHandlers,
mentionFolderNav,
}: UseMentionKeyboardProps) {
const {
showMentionMenu,
openSubmenuFor,
mentionActiveIndex,
submenuActiveIndex,
setMentionActiveIndex,
setSubmenuActiveIndex,
setOpenSubmenuFor,
setSubmenuQueryStart,
getCaretPos,
getActiveMentionQueryAtPosition,
@@ -58,101 +98,65 @@ export function useMentionKeyboard({
scrollActiveItemIntoView,
} = mentionMenu
const currentFolder = mentionFolderNav?.currentFolder ?? null
const isInFolder = mentionFolderNav?.isInFolder ?? false
const {
pastChats,
workflows,
knowledgeBases,
blocksList,
workflowBlocks,
templatesList,
logsList,
ensurePastChatsLoaded,
ensureWorkflowsLoaded,
ensureKnowledgeLoaded,
ensureBlocksLoaded,
ensureWorkflowBlocksLoaded,
ensureTemplatesLoaded,
ensureLogsLoaded,
} = mentionData
/**
* Map of folder IDs to insert handlers
*/
const insertHandlerMap = useMemo(
(): Record<MentionFolderId, (item: any) => void> => ({
chats: insertHandlers.insertPastChatMention,
workflows: insertHandlers.insertWorkflowMention,
knowledge: insertHandlers.insertKnowledgeMention,
blocks: insertHandlers.insertBlockMention,
'workflow-blocks': insertHandlers.insertWorkflowBlockMention,
templates: insertHandlers.insertTemplateMention,
logs: insertHandlers.insertLogMention,
}),
[insertHandlers]
)
/**
* Get data array for a folder from mentionData
*/
const getFolderData = useCallback(
(folderId: MentionFolderId) => getFolderDataUtil(mentionData, folderId),
[mentionData]
)
/**
* Filter items for a folder based on query using config's filterFn
*/
const filterFolderItems = useCallback(
(folderId: MentionFolderId, query: string): any[] => {
const config = FOLDER_CONFIGS[folderId]
const items = getFolderData(folderId)
if (!query) return items
const q = query.toLowerCase()
return items.filter((item) => config.filterFn(item, q))
},
[getFolderData]
)
/**
* Ensure data is loaded for a folder
*/
const ensureFolderLoaded = useCallback(
(folderId: MentionFolderId): void => {
const ensureFn = getFolderEnsureLoadedUtil(mentionData, folderId)
if (ensureFn) void ensureFn()
},
[mentionData]
)
const {
insertPastChatMention,
insertWorkflowMention,
insertKnowledgeMention,
insertBlockMention,
insertWorkflowBlockMention,
insertTemplateMention,
insertLogMention,
insertDocsMention,
} = insertHandlers
/**
* Build aggregated list matching the portal's ordering
*/
const buildAggregatedList = useCallback(
(query: string): Array<{ type: MentionFolderId | 'docs'; value: any }> => {
(query: string) => {
const q = query.toLowerCase()
const result: Array<{ type: MentionFolderId | 'docs'; value: any }> = []
for (const folderId of FOLDER_ORDER) {
const filtered = filterFolderItems(folderId, q)
filtered.forEach((item) => {
result.push({ type: folderId, value: item })
})
}
if ('docs'.includes(q)) {
result.push({ type: 'docs', value: null })
}
return result
return [
...pastChats
.filter((c) => (c.title || 'New Chat').toLowerCase().includes(q))
.map((c) => ({ type: 'Chats' as const, value: c })),
...workflows
.filter((w) => (w.name || 'Untitled Workflow').toLowerCase().includes(q))
.map((w) => ({ type: 'Workflows' as const, value: w })),
...knowledgeBases
.filter((k) => (k.name || 'Untitled').toLowerCase().includes(q))
.map((k) => ({ type: 'Knowledge' as const, value: k })),
...blocksList
.filter((b) => (b.name || b.id).toLowerCase().includes(q))
.map((b) => ({ type: 'Blocks' as const, value: b })),
...workflowBlocks
.filter((b) => (b.name || b.id).toLowerCase().includes(q))
.map((b) => ({ type: 'Workflow Blocks' as const, value: b })),
...templatesList
.filter((t) => (t.name || 'Untitled Template').toLowerCase().includes(q))
.map((t) => ({ type: 'Templates' as const, value: t })),
...logsList
.filter((l) => (l.workflowName || 'Untitled Workflow').toLowerCase().includes(q))
.map((l) => ({ type: 'Logs' as const, value: l })),
]
},
[filterFolderItems]
)
/**
* Generic navigation helper for navigating through items
*/
const navigateItems = useCallback(
(
direction: 'up' | 'down',
itemCount: number,
setIndex: (fn: (prev: number) => number) => void
) => {
setIndex((prev) => {
const last = Math.max(0, itemCount - 1)
if (itemCount === 0) return 0
const next =
direction === 'down' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
},
[scrollActiveItemIntoView]
[pastChats, workflows, knowledgeBases, blocksList, workflowBlocks, templatesList, logsList]
)
/**
@@ -165,36 +169,143 @@ export function useMentionKeyboard({
e.preventDefault()
const caretPos = getCaretPos()
const active = getActiveMentionQueryAtPosition(caretPos)
const mainQ = (!isInFolder ? active?.query || '' : '').toLowerCase()
const direction = e.key === 'ArrowDown' ? 'down' : 'up'
const mainQ = (!openSubmenuFor ? active?.query || '' : '').toLowerCase()
// When there's a query, we show aggregated filtered view (no folders)
const showAggregatedView = mainQ.length > 0
if (showAggregatedView && !isInFolder) {
const aggregatedList = buildAggregatedList(mainQ)
navigateItems(direction, aggregatedList.length, setSubmenuActiveIndex)
const aggregatedList = showAggregatedView ? buildAggregatedList(mainQ) : []
// When showing aggregated filtered view, navigate through the aggregated list
if (showAggregatedView && !openSubmenuFor) {
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, aggregatedList.length - 1)
if (aggregatedList.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
return true
}
if (currentFolder && FOLDER_CONFIGS[currentFolder as MentionFolderId]) {
// Handle submenu navigation
if (openSubmenuFor === 'Chats') {
const q = getSubmenuQuery().toLowerCase()
const filtered = filterFolderItems(currentFolder as MentionFolderId, q)
navigateItems(direction, filtered.length, setSubmenuActiveIndex)
return true
const filtered = pastChats.filter((c) => (c.title || 'New Chat').toLowerCase().includes(q))
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Workflows') {
const q = getSubmenuQuery().toLowerCase()
const filtered = workflows.filter((w) =>
(w.name || 'Untitled Workflow').toLowerCase().includes(q)
)
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Knowledge') {
const q = getSubmenuQuery().toLowerCase()
const filtered = knowledgeBases.filter((k) =>
(k.name || 'Untitled').toLowerCase().includes(q)
)
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Blocks') {
const q = getSubmenuQuery().toLowerCase()
const filtered = blocksList.filter((b) => (b.name || b.id).toLowerCase().includes(q))
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Workflow Blocks') {
const q = getSubmenuQuery().toLowerCase()
const filtered = workflowBlocks.filter((b) => (b.name || b.id).toLowerCase().includes(q))
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Templates') {
const q = getSubmenuQuery().toLowerCase()
const filtered = templatesList.filter((t) =>
(t.name || 'Untitled Template').toLowerCase().includes(q)
)
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Logs') {
const q = getSubmenuQuery().toLowerCase()
const filtered = logsList.filter((l) =>
[l.workflowName, l.trigger || ''].join(' ').toLowerCase().includes(q)
)
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else {
// Navigate through folder options when no query
const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ))
setMentionActiveIndex((prev) => {
const last = Math.max(0, filteredMain.length - 1)
if (filteredMain.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
}
navigateItems(direction, ROOT_MENU_ITEM_COUNT, setMentionActiveIndex)
return true
},
[
showMentionMenu,
isInFolder,
currentFolder,
openSubmenuFor,
mentionActiveIndex,
submenuActiveIndex,
buildAggregatedList,
filterFolderItems,
navigateItems,
pastChats,
workflows,
knowledgeBases,
blocksList,
workflowBlocks,
templatesList,
logsList,
getCaretPos,
getActiveMentionQueryAtPosition,
getSubmenuQuery,
scrollActiveItemIntoView,
setMentionActiveIndex,
setSubmenuActiveIndex,
]
@@ -205,30 +316,65 @@ export function useMentionKeyboard({
*/
const handleArrowRight = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (!showMentionMenu || e.key !== 'ArrowRight' || !mentionFolderNav) return false
if (!showMentionMenu || e.key !== 'ArrowRight') return false
const caretPos = getCaretPos()
const active = getActiveMentionQueryAtPosition(caretPos)
const mainQ = (active?.query || '').toLowerCase()
const showAggregatedView = mainQ.length > 0
if (mainQ.length > 0) return false
// Don't handle arrow right in aggregated view (user is filtering, not navigating folders)
if (showAggregatedView) return false
e.preventDefault()
const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ))
const selected = filteredMain[mentionActiveIndex]
const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length
if (isDocsSelected) {
if (selected === 'Chats') {
resetActiveMentionQuery()
insertHandlers.insertDocsMention()
return true
}
const selectedFolderId = FOLDER_ORDER[mentionActiveIndex]
if (selectedFolderId) {
const config = FOLDER_CONFIGS[selectedFolderId]
resetActiveMentionQuery()
mentionFolderNav.openFolder(selectedFolderId, config.title)
setOpenSubmenuFor('Chats')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
ensureFolderLoaded(selectedFolderId)
void ensurePastChatsLoaded()
} else if (selected === 'Workflows') {
resetActiveMentionQuery()
setOpenSubmenuFor('Workflows')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureWorkflowsLoaded()
} else if (selected === 'Knowledge') {
resetActiveMentionQuery()
setOpenSubmenuFor('Knowledge')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureKnowledgeLoaded()
} else if (selected === 'Blocks') {
resetActiveMentionQuery()
setOpenSubmenuFor('Blocks')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureBlocksLoaded()
} else if (selected === 'Workflow Blocks') {
resetActiveMentionQuery()
setOpenSubmenuFor('Workflow Blocks')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureWorkflowBlocksLoaded()
} else if (selected === 'Docs') {
resetActiveMentionQuery()
insertDocsMention()
} else if (selected === 'Templates') {
resetActiveMentionQuery()
setOpenSubmenuFor('Templates')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureTemplatesLoaded()
} else if (selected === 'Logs') {
resetActiveMentionQuery()
setOpenSubmenuFor('Logs')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureLogsLoaded()
}
return true
@@ -236,13 +382,21 @@ export function useMentionKeyboard({
[
showMentionMenu,
mentionActiveIndex,
mentionFolderNav,
openSubmenuFor,
getCaretPos,
getActiveMentionQueryAtPosition,
resetActiveMentionQuery,
setOpenSubmenuFor,
setSubmenuActiveIndex,
setSubmenuQueryStart,
ensureFolderLoaded,
insertHandlers,
ensurePastChatsLoaded,
ensureWorkflowsLoaded,
ensureKnowledgeLoaded,
ensureBlocksLoaded,
ensureWorkflowBlocksLoaded,
ensureTemplatesLoaded,
ensureLogsLoaded,
insertDocsMention,
]
)
@@ -253,16 +407,16 @@ export function useMentionKeyboard({
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (!showMentionMenu || e.key !== 'ArrowLeft') return false
if (isInFolder && mentionFolderNav) {
if (openSubmenuFor) {
e.preventDefault()
mentionFolderNav.closeFolder()
setOpenSubmenuFor(null)
setSubmenuQueryStart(null)
return true
}
return false
},
[showMentionMenu, isInFolder, mentionFolderNav, setSubmenuQueryStart]
[showMentionMenu, openSubmenuFor, setOpenSubmenuFor, setSubmenuQueryStart]
)
/**
@@ -275,74 +429,179 @@ export function useMentionKeyboard({
e.preventDefault()
const caretPos = getCaretPos()
const active = getActiveMentionQueryAtPosition(caretPos)
const mainQ = (!isInFolder ? active?.query || '' : '').toLowerCase()
const mainQ = (active?.query || '').toLowerCase()
const showAggregatedView = mainQ.length > 0
const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ))
const selected = filteredMain[mentionActiveIndex]
if (showAggregatedView && !isInFolder) {
// Handle selection in aggregated filtered view
if (showAggregatedView && !openSubmenuFor) {
const aggregated = buildAggregatedList(mainQ)
const idx = Math.max(0, Math.min(submenuActiveIndex, aggregated.length - 1))
const chosen = aggregated[idx]
if (chosen) {
if (chosen.type === 'docs') {
insertHandlers.insertDocsMention()
} else {
const handler = insertHandlerMap[chosen.type]
handler(chosen.value)
}
if (chosen.type === 'Chats') insertPastChatMention(chosen.value as ChatItem)
else if (chosen.type === 'Workflows') insertWorkflowMention(chosen.value as WorkflowItem)
else if (chosen.type === 'Knowledge')
insertKnowledgeMention(chosen.value as KnowledgeItem)
else if (chosen.type === 'Workflow Blocks')
insertWorkflowBlockMention(chosen.value as BlockItem)
else if (chosen.type === 'Blocks') insertBlockMention(chosen.value as BlockItem)
else if (chosen.type === 'Templates') insertTemplateMention(chosen.value as TemplateItem)
else if (chosen.type === 'Logs') insertLogMention(chosen.value as LogItem)
}
return true
}
if (isInFolder && currentFolder && FOLDER_CONFIGS[currentFolder as MentionFolderId]) {
const folderId = currentFolder as MentionFolderId
const q = getSubmenuQuery().toLowerCase()
const filtered = filterFolderItems(folderId, q)
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
const handler = insertHandlerMap[folderId]
handler(chosen)
setSubmenuQueryStart(null)
}
return true
}
const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length
if (isDocsSelected) {
// Handle folder navigation when no query
if (!openSubmenuFor && selected === 'Chats') {
resetActiveMentionQuery()
insertHandlers.insertDocsMention()
return true
}
const selectedFolderId = FOLDER_ORDER[mentionActiveIndex]
if (selectedFolderId && mentionFolderNav) {
const config = FOLDER_CONFIGS[selectedFolderId]
resetActiveMentionQuery()
mentionFolderNav.openFolder(selectedFolderId, config.title)
setOpenSubmenuFor('Chats')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
ensureFolderLoaded(selectedFolderId)
void ensurePastChatsLoaded()
} else if (openSubmenuFor === 'Chats') {
const q = getSubmenuQuery().toLowerCase()
const filtered = pastChats.filter((c) => (c.title || 'New Chat').toLowerCase().includes(q))
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertPastChatMention(chosen)
setSubmenuQueryStart(null)
}
} else if (!openSubmenuFor && selected === 'Workflows') {
resetActiveMentionQuery()
setOpenSubmenuFor('Workflows')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureWorkflowsLoaded()
} else if (openSubmenuFor === 'Workflows') {
const q = getSubmenuQuery().toLowerCase()
const filtered = workflows.filter((w) =>
(w.name || 'Untitled Workflow').toLowerCase().includes(q)
)
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertWorkflowMention(chosen)
setSubmenuQueryStart(null)
}
} else if (!openSubmenuFor && selected === 'Knowledge') {
resetActiveMentionQuery()
setOpenSubmenuFor('Knowledge')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureKnowledgeLoaded()
} else if (openSubmenuFor === 'Knowledge') {
const q = getSubmenuQuery().toLowerCase()
const filtered = knowledgeBases.filter((k) =>
(k.name || 'Untitled').toLowerCase().includes(q)
)
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertKnowledgeMention(chosen)
setSubmenuQueryStart(null)
}
} else if (!openSubmenuFor && selected === 'Blocks') {
resetActiveMentionQuery()
setOpenSubmenuFor('Blocks')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureBlocksLoaded()
} else if (openSubmenuFor === 'Blocks') {
const q = getSubmenuQuery().toLowerCase()
const filtered = blocksList.filter((b) => (b.name || b.id).toLowerCase().includes(q))
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertBlockMention(chosen)
setSubmenuQueryStart(null)
}
} else if (!openSubmenuFor && selected === 'Workflow Blocks') {
resetActiveMentionQuery()
setOpenSubmenuFor('Workflow Blocks')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureWorkflowBlocksLoaded()
} else if (openSubmenuFor === 'Workflow Blocks') {
const q = getSubmenuQuery().toLowerCase()
const filtered = workflowBlocks.filter((b) => (b.name || b.id).toLowerCase().includes(q))
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertWorkflowBlockMention(chosen)
setSubmenuQueryStart(null)
}
} else if (!openSubmenuFor && selected === 'Docs') {
resetActiveMentionQuery()
insertDocsMention()
} else if (!openSubmenuFor && selected === 'Templates') {
resetActiveMentionQuery()
setOpenSubmenuFor('Templates')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureTemplatesLoaded()
} else if (!openSubmenuFor && selected === 'Logs') {
resetActiveMentionQuery()
setOpenSubmenuFor('Logs')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureLogsLoaded()
} else if (openSubmenuFor === 'Templates') {
const q = getSubmenuQuery().toLowerCase()
const filtered = templatesList.filter((t) =>
(t.name || 'Untitled Template').toLowerCase().includes(q)
)
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertTemplateMention(chosen)
setSubmenuQueryStart(null)
}
} else if (openSubmenuFor === 'Logs') {
const q = getSubmenuQuery().toLowerCase()
const filtered = logsList.filter((l) =>
[l.workflowName, l.trigger || ''].join(' ').toLowerCase().includes(q)
)
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertLogMention(chosen)
setSubmenuQueryStart(null)
}
}
return true
},
[
showMentionMenu,
isInFolder,
currentFolder,
openSubmenuFor,
mentionActiveIndex,
submenuActiveIndex,
mentionFolderNav,
buildAggregatedList,
filterFolderItems,
insertHandlerMap,
pastChats,
workflows,
knowledgeBases,
blocksList,
workflowBlocks,
templatesList,
logsList,
getCaretPos,
getActiveMentionQueryAtPosition,
getSubmenuQuery,
resetActiveMentionQuery,
setOpenSubmenuFor,
setSubmenuActiveIndex,
setSubmenuQueryStart,
ensureFolderLoaded,
insertHandlers,
ensurePastChatsLoaded,
ensureWorkflowsLoaded,
ensureKnowledgeLoaded,
ensureBlocksLoaded,
ensureWorkflowBlocksLoaded,
ensureTemplatesLoaded,
ensureLogsLoaded,
insertPastChatMention,
insertWorkflowMention,
insertKnowledgeMention,
insertBlockMention,
insertWorkflowBlockMention,
insertTemplateMention,
insertLogMention,
insertDocsMention,
]
)

View File

@@ -1,6 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { SCROLL_TOLERANCE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import { createLogger } from '@sim/logger'
import type { ChatContext } from '@/stores/panel'
import { SCROLL_TOLERANCE } from '../constants'
const logger = createLogger('useMentionMenu')
interface UseMentionMenuProps {
/** Current message text */

View File

@@ -49,6 +49,7 @@ export function useTextareaAutoResize({
const styles = window.getComputedStyle(textarea)
// Copy all text rendering properties exactly (but NOT color - overlay needs visible text)
overlay.style.font = styles.font
overlay.style.fontSize = styles.fontSize
overlay.style.fontFamily = styles.fontFamily
@@ -65,6 +66,7 @@ export function useTextareaAutoResize({
overlay.style.textTransform = styles.textTransform
overlay.style.textIndent = styles.textIndent
// Copy box model properties exactly to ensure identical text flow
overlay.style.padding = styles.padding
overlay.style.paddingTop = styles.paddingTop
overlay.style.paddingRight = styles.paddingRight
@@ -78,6 +80,7 @@ export function useTextareaAutoResize({
overlay.style.border = styles.border
overlay.style.borderWidth = styles.borderWidth
// Copy text wrapping and breaking properties
overlay.style.whiteSpace = styles.whiteSpace
overlay.style.wordBreak = styles.wordBreak
overlay.style.wordWrap = styles.wordWrap
@@ -88,17 +91,20 @@ export function useTextareaAutoResize({
overlay.style.direction = styles.direction
overlay.style.hyphens = (styles as any).hyphens ?? ''
// Critical: Match dimensions exactly
const textareaWidth = textarea.clientWidth
const textareaHeight = textarea.clientHeight
overlay.style.width = `${textareaWidth}px`
overlay.style.height = `${textareaHeight}px`
// Match max-height behavior
const computedMaxHeight = styles.maxHeight
if (computedMaxHeight && computedMaxHeight !== 'none') {
overlay.style.maxHeight = computedMaxHeight
}
// Ensure scroll positions are perfectly synced
overlay.scrollTop = textarea.scrollTop
overlay.scrollLeft = textarea.scrollLeft
})
@@ -113,20 +119,25 @@ export function useTextareaAutoResize({
const overlay = overlayRef.current
if (!textarea || !overlay) return
// Store current cursor position to determine if user is typing at the end
const cursorPos = textarea.selectionStart ?? 0
const isAtEnd = cursorPos === message.length
const wasScrolledToBottom =
textarea.scrollHeight - textarea.scrollTop - textarea.clientHeight < 5
// Reset height to auto to get proper scrollHeight
textarea.style.height = 'auto'
overlay.style.height = 'auto'
// Force a reflow to ensure accurate scrollHeight
void textarea.offsetHeight
void overlay.offsetHeight
// Get the scroll height (this includes all content, including trailing newlines)
const scrollHeight = textarea.scrollHeight
const nextHeight = Math.min(scrollHeight, MAX_TEXTAREA_HEIGHT)
// Apply height to BOTH elements simultaneously
const heightString = `${nextHeight}px`
const overflowString = scrollHeight > MAX_TEXTAREA_HEIGHT ? 'auto' : 'hidden'
@@ -135,18 +146,22 @@ export function useTextareaAutoResize({
overlay.style.height = heightString
overlay.style.overflowY = overflowString
// Force another reflow after height change
void textarea.offsetHeight
void overlay.offsetHeight
// Maintain scroll behavior: if user was at bottom or typing at end, keep them at bottom
if ((isAtEnd || wasScrolledToBottom) && scrollHeight > nextHeight) {
const scrollValue = scrollHeight
textarea.scrollTop = scrollValue
overlay.scrollTop = scrollValue
} else {
// Otherwise, sync scroll positions
overlay.scrollTop = textarea.scrollTop
overlay.scrollLeft = textarea.scrollLeft
}
// Sync all other styles after height change
syncOverlayStyles.current()
}, [message, selectedContexts, textareaRef])
@@ -177,15 +192,19 @@ export function useTextareaAutoResize({
const overlay = overlayRef.current
if (!textarea || !overlay || !containerRef || typeof window === 'undefined') return
// Initial sync
syncOverlayStyles.current()
// Observe the CONTAINER - when pills wrap, container height changes
if (typeof ResizeObserver !== 'undefined' && !containerResizeObserverRef.current) {
containerResizeObserverRef.current = new ResizeObserver(() => {
// Container size changed (pills wrapped) - sync immediately
syncOverlayStyles.current()
})
containerResizeObserverRef.current.observe(containerRef)
}
// ALSO observe the textarea for its own size changes
if (typeof ResizeObserver !== 'undefined' && !textareaResizeObserverRef.current) {
textareaResizeObserverRef.current = new ResizeObserver(() => {
syncOverlayStyles.current()
@@ -193,6 +212,7 @@ export function useTextareaAutoResize({
textareaResizeObserverRef.current.observe(textarea)
}
// Setup MutationObserver to detect style changes
const mutationObserver = new MutationObserver(() => {
syncOverlayStyles.current()
})
@@ -201,9 +221,11 @@ export function useTextareaAutoResize({
attributeFilter: ['style', 'class'],
})
// Listen to window resize events (for browser window resizing)
const handleResize = () => syncOverlayStyles.current()
window.addEventListener('resize', handleResize)
// Cleanup
return () => {
mutationObserver.disconnect()
window.removeEventListener('resize', handleResize)

View File

@@ -18,21 +18,12 @@ import { cn } from '@/lib/core/utils/cn'
import {
AttachedFilesDisplay,
ContextPills,
type MentionFolderNav,
MentionMenu,
ModelSelector,
ModeSelector,
type SlashFolderNav,
SlashMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import {
ALL_COMMAND_IDS,
getCommandDisplayLabel,
getNextIndex,
NEAR_TOP_THRESHOLD,
TOP_LEVEL_COMMANDS,
WEB_COMMANDS,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import { NEAR_TOP_THRESHOLD } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import {
useContextManagement,
useFileAttachments,
@@ -119,6 +110,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
},
ref
) => {
// Refs and external hooks
const { data: session } = useSession()
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -130,18 +122,19 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
selectedModelOverride !== undefined ? selectedModelOverride : copilotStore.selectedModel
const setSelectedModel = onModelChangeOverride || copilotStore.setSelectedModel
// Internal state
const [internalMessage, setInternalMessage] = useState('')
const [isNearTop, setIsNearTop] = useState(false)
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const [inputContainerRef, setInputContainerRef] = useState<HTMLDivElement | null>(null)
const [showSlashMenu, setShowSlashMenu] = useState(false)
const [slashFolderNav, setSlashFolderNav] = useState<SlashFolderNav | null>(null)
const [mentionFolderNav, setMentionFolderNav] = useState<MentionFolderNav | null>(null)
// Controlled vs uncontrolled message state
const message = controlledValue !== undefined ? controlledValue : internalMessage
const setMessage =
controlledValue !== undefined ? onControlledChange || (() => {}) : setInternalMessage
// Effective placeholder
const effectivePlaceholder =
placeholder ||
(mode === 'ask'
@@ -150,8 +143,11 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
? 'Plan your workflow'
: 'Plan, search, build anything')
// Custom hooks - order matters for ref sharing
// Context management (manages selectedContexts state)
const contextManagement = useContextManagement({ message, initialContexts })
// Mention menu
const mentionMenu = useMentionMenu({
message,
selectedContexts: contextManagement.selectedContexts,
@@ -159,6 +155,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
onMessageChange: setMessage,
})
// Mention token utilities
const mentionTokensWithContext = useMentionTokens({
message,
selectedContexts: contextManagement.selectedContexts,
@@ -186,21 +183,22 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
isLoading,
})
// Insert mention handlers
const insertHandlers = useMentionInsertHandlers({
mentionMenu,
workflowId: workflowId || null,
selectedContexts: contextManagement.selectedContexts,
onContextAdd: contextManagement.addContext,
mentionFolderNav,
})
// Keyboard navigation hook
const mentionKeyboard = useMentionKeyboard({
mentionMenu,
mentionData,
insertHandlers,
mentionFolderNav,
})
// Expose focus method to parent
useImperativeHandle(
ref,
() => ({
@@ -217,6 +215,17 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
[mentionMenu.textareaRef]
)
// Note: textarea auto-resize is handled by the useTextareaAutoResize hook
// Load workflows on mount if we have a workflowId
useEffect(() => {
if (workflowId) {
void mentionData.ensureWorkflowsLoaded()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowId])
// Detect if input is near top of screen
useEffect(() => {
const checkPosition = () => {
if (containerRef) {
@@ -244,6 +253,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}, [containerRef])
// Also check position when mention menu opens
useEffect(() => {
if (mentionMenu.showMentionMenu && containerRef) {
const rect = containerRef.getBoundingClientRect()
@@ -251,8 +261,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}, [mentionMenu.showMentionMenu, containerRef])
// Preload mention data when query is active
useEffect(() => {
if (!mentionMenu.showMentionMenu || mentionFolderNav?.isInFolder) {
if (!mentionMenu.showMentionMenu || mentionMenu.openSubmenuFor) {
return
}
@@ -262,31 +273,38 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
.toLowerCase()
if (q && q.length > 0) {
// Prefetch all lists when there's any query for instant filtering
void mentionData.ensurePastChatsLoaded()
// workflows and workflow-blocks auto-load from stores
void mentionData.ensureWorkflowsLoaded()
void mentionData.ensureWorkflowBlocksLoaded()
void mentionData.ensureKnowledgeLoaded()
void mentionData.ensureBlocksLoaded()
void mentionData.ensureTemplatesLoaded()
void mentionData.ensureLogsLoaded()
// Reset to first item when query changes
mentionMenu.setSubmenuActiveIndex(0)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0))
}
// Only depend on values that trigger data loading, not the entire objects
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mentionMenu.showMentionMenu, mentionFolderNav?.isInFolder, message])
}, [mentionMenu.showMentionMenu, mentionMenu.openSubmenuFor, message])
// When switching into a submenu, select the first item and scroll to it
useEffect(() => {
if (mentionFolderNav?.isInFolder) {
if (mentionMenu.openSubmenuFor) {
mentionMenu.setSubmenuActiveIndex(0)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mentionFolderNav?.isInFolder])
}, [mentionMenu.openSubmenuFor])
// Handlers
const handleSubmit = useCallback(
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
const targetMessage = overrideMessage ?? message
const trimmedMessage = targetMessage.trim()
// Allow submission even when isLoading - store will queue the message
if (!trimmedMessage || disabled) return
const failedUploads = fileAttachments.attachedFiles.filter((f) => !f.uploading && !f.key)
@@ -359,12 +377,17 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const handleSlashCommandSelect = useCallback(
(command: string) => {
const displayLabel = getCommandDisplayLabel(command)
mentionMenu.replaceActiveSlashWith(displayLabel)
// Capitalize the command for display
const capitalizedCommand = command.charAt(0).toUpperCase() + command.slice(1)
// Replace the active slash query with the capitalized command
mentionMenu.replaceActiveSlashWith(capitalizedCommand)
// Add as a context so it gets highlighted
contextManagement.addContext({
kind: 'slash_command',
command,
label: displayLabel,
label: capitalizedCommand,
})
setShowSlashMenu(false)
@@ -375,13 +398,12 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
// Escape key handling
if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) {
e.preventDefault()
if (mentionFolderNav?.isInFolder) {
mentionFolderNav.closeFolder()
if (mentionMenu.openSubmenuFor) {
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setSubmenuQueryStart(null)
} else if (slashFolderNav?.isInFolder) {
slashFolderNav.closeFolder()
} else {
mentionMenu.closeMentionMenu()
setShowSlashMenu(false)
@@ -389,34 +411,65 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
return
}
// Arrow navigation in slash menu
if (showSlashMenu) {
const TOP_LEVEL_COMMANDS = ['fast', 'plan', 'debug', 'research', 'deploy', 'superagent']
const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl']
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
const caretPos = mentionMenu.getCaretPos()
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
const query = activeSlash?.query.trim().toLowerCase() || ''
const showAggregatedView = query.length > 0
const direction = e.key === 'ArrowDown' ? 'down' : 'up'
const isInFolder = slashFolderNav?.isInFolder ?? false
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
if (isInFolder) {
if (mentionMenu.openSubmenuFor === 'Web') {
// Navigate in Web submenu
const last = WEB_COMMANDS.length - 1
mentionMenu.setSubmenuActiveIndex((prev) => {
const next = getNextIndex(prev, direction, WEB_COMMANDS.length - 1)
const next =
e.key === 'ArrowDown'
? prev >= last
? 0
: prev + 1
: prev <= 0
? last
: prev - 1
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
} else if (showAggregatedView) {
const filtered = ALL_COMMAND_IDS.filter((cmd) => cmd.includes(query))
// Navigate in filtered view
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
const last = Math.max(0, filtered.length - 1)
mentionMenu.setSubmenuActiveIndex((prev) => {
if (filtered.length === 0) return 0
const next = getNextIndex(prev, direction, filtered.length - 1)
const next =
e.key === 'ArrowDown'
? prev >= last
? 0
: prev + 1
: prev <= 0
? last
: prev - 1
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
} else {
// Navigate in folder view (top-level + Web folder)
const totalItems = TOP_LEVEL_COMMANDS.length + 1 // +1 for Web folder
const last = totalItems - 1
mentionMenu.setMentionActiveIndex((prev) => {
const next = getNextIndex(prev, direction, TOP_LEVEL_COMMANDS.length)
const next =
e.key === 'ArrowDown'
? prev >= last
? 0
: prev + 1
: prev <= 0
? last
: prev - 1
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
@@ -424,54 +477,69 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
return
}
// Arrow right to enter Web submenu
if (e.key === 'ArrowRight') {
e.preventDefault()
if (!showAggregatedView && !isInFolder) {
if (!showAggregatedView && !mentionMenu.openSubmenuFor) {
// Check if Web folder is selected (it's after all top-level commands)
if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) {
slashFolderNav?.openWebFolder()
mentionMenu.setOpenSubmenuFor('Web')
mentionMenu.setSubmenuActiveIndex(0)
}
}
return
}
// Arrow left to exit submenu
if (e.key === 'ArrowLeft') {
e.preventDefault()
if (isInFolder) {
slashFolderNav?.closeFolder()
if (mentionMenu.openSubmenuFor) {
mentionMenu.setOpenSubmenuFor(null)
}
return
}
}
// Arrow navigation in mention menu
if (mentionKeyboard.handleArrowNavigation(e)) return
if (mentionKeyboard.handleArrowRight(e)) return
if (mentionKeyboard.handleArrowLeft(e)) return
// Enter key handling
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault()
if (showSlashMenu) {
const TOP_LEVEL_COMMANDS = ['fast', 'plan', 'debug', 'research', 'deploy', 'superagent']
const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl']
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
const caretPos = mentionMenu.getCaretPos()
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
const query = activeSlash?.query.trim().toLowerCase() || ''
const showAggregatedView = query.length > 0
const isInFolder = slashFolderNav?.isInFolder ?? false
if (isInFolder) {
if (mentionMenu.openSubmenuFor === 'Web') {
// Select from Web submenu
const selectedCommand =
WEB_COMMANDS[mentionMenu.submenuActiveIndex]?.id || WEB_COMMANDS[0].id
WEB_COMMANDS[mentionMenu.submenuActiveIndex] || WEB_COMMANDS[0]
handleSlashCommandSelect(selectedCommand)
} else if (showAggregatedView) {
const filtered = ALL_COMMAND_IDS.filter((cmd) => cmd.includes(query))
// Select from filtered view
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
if (filtered.length > 0) {
const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0]
handleSlashCommandSelect(selectedCommand)
}
} else {
// Folder navigation view
const selectedIndex = mentionMenu.mentionActiveIndex
if (selectedIndex < TOP_LEVEL_COMMANDS.length) {
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex].id)
// Top-level command selected
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex])
} else if (selectedIndex === TOP_LEVEL_COMMANDS.length) {
slashFolderNav?.openWebFolder()
// Web folder selected - open it
mentionMenu.setOpenSubmenuFor('Web')
mentionMenu.setSubmenuActiveIndex(0)
}
}
return
@@ -484,6 +552,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
return
}
// Handle mention token behavior (backspace, delete, arrow keys) when menu is closed
if (!mentionMenu.showMentionMenu) {
const textarea = mentionMenu.textareaRef.current
const selStart = textarea?.selectionStart ?? 0
@@ -492,8 +561,11 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
if (e.key === 'Backspace' || e.key === 'Delete') {
if (selectionLength > 0) {
// Multi-character selection: Clean up contexts for any overlapping mentions
// but let the default behavior handle the actual text deletion
mentionTokensWithContext.removeContextsInSelection(selStart, selEnd)
} else {
// Single character delete - check if cursor is inside/at a mention token
const ranges = mentionTokensWithContext.computeMentionRanges()
const target =
e.key === 'Backspace'
@@ -532,6 +604,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}
// Prevent typing inside token
if (e.key.length === 1 || e.key === 'Space') {
const blocked =
selectionLength === 0 && !!mentionTokensWithContext.findRangeContaining(selStart)
@@ -556,8 +629,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
message,
mentionTokensWithContext,
showSlashMenu,
slashFolderNav,
mentionFolderNav,
]
)
@@ -566,17 +637,21 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const newValue = e.target.value
setMessage(newValue)
// Skip mention menu logic if mentions are disabled
if (disableMentions) return
const caret = e.target.selectionStart ?? newValue.length
// Check for @ mention trigger
const activeMention = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue)
// Check for / slash command trigger
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caret, newValue)
if (activeMention) {
setShowSlashMenu(false)
mentionMenu.setShowMentionMenu(true)
mentionMenu.setInAggregated(false)
if (mentionFolderNav?.isInFolder) {
if (mentionMenu.openSubmenuFor) {
mentionMenu.setSubmenuActiveIndex(0)
} else {
mentionMenu.setMentionActiveIndex(0)
@@ -595,7 +670,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
setShowSlashMenu(false)
}
},
[setMessage, mentionMenu, disableMentions, mentionFolderNav]
[setMessage, mentionMenu, disableMentions]
)
const handleSelectAdjust = useCallback(() => {
@@ -611,66 +686,84 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}, [mentionMenu.textareaRef, mentionTokensWithContext])
const insertTriggerAndOpenMenu = useCallback(
(trigger: '@' | '/') => {
if (disabled || isLoading) return
const textarea = mentionMenu.textareaRef.current
if (!textarea) return
const handleOpenMentionMenuWithAt = useCallback(() => {
if (disabled || isLoading) return
const textarea = mentionMenu.textareaRef.current
if (!textarea) return
textarea.focus()
const pos = textarea.selectionStart ?? message.length
const needsSpaceBefore = pos > 0 && !/\s/.test(message.charAt(pos - 1))
const insertText = needsSpaceBefore ? ' @' : '@'
const start = textarea.selectionStart ?? message.length
const end = textarea.selectionEnd ?? message.length
const before = message.slice(0, start)
const after = message.slice(end)
const next = `${before}${insertText}${after}`
setMessage(next)
setTimeout(() => {
const newPos = before.length + insertText.length
textarea.setSelectionRange(newPos, newPos)
textarea.focus()
const start = textarea.selectionStart ?? message.length
const end = textarea.selectionEnd ?? message.length
const needsSpaceBefore = start > 0 && !/\s/.test(message.charAt(start - 1))
}, 0)
const insertText = needsSpaceBefore ? ` ${trigger}` : trigger
const before = message.slice(0, start)
const after = message.slice(end)
setMessage(`${before}${insertText}${after}`)
mentionMenu.setShowMentionMenu(true)
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setMentionActiveIndex(0)
mentionMenu.setSubmenuActiveIndex(0)
}, [disabled, isLoading, mentionMenu, message, setMessage])
setTimeout(() => {
const newPos = before.length + insertText.length
textarea.setSelectionRange(newPos, newPos)
textarea.focus()
}, 0)
const handleOpenSlashMenu = useCallback(() => {
if (disabled || isLoading) return
const textarea = mentionMenu.textareaRef.current
if (!textarea) return
textarea.focus()
const pos = textarea.selectionStart ?? message.length
const needsSpaceBefore = pos > 0 && !/\s/.test(message.charAt(pos - 1))
if (trigger === '@') {
mentionMenu.setShowMentionMenu(true)
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setMentionActiveIndex(0)
} else {
setShowSlashMenu(true)
}
mentionMenu.setSubmenuActiveIndex(0)
},
[disabled, isLoading, mentionMenu, message, setMessage]
)
const insertText = needsSpaceBefore ? ' /' : '/'
const start = textarea.selectionStart ?? message.length
const end = textarea.selectionEnd ?? message.length
const before = message.slice(0, start)
const after = message.slice(end)
const next = `${before}${insertText}${after}`
setMessage(next)
const handleOpenMentionMenuWithAt = useCallback(
() => insertTriggerAndOpenMenu('@'),
[insertTriggerAndOpenMenu]
)
setTimeout(() => {
const newPos = before.length + insertText.length
textarea.setSelectionRange(newPos, newPos)
textarea.focus()
}, 0)
const handleOpenSlashMenu = useCallback(
() => insertTriggerAndOpenMenu('/'),
[insertTriggerAndOpenMenu]
)
setShowSlashMenu(true)
mentionMenu.setSubmenuActiveIndex(0)
}, [disabled, isLoading, mentionMenu, message, setMessage])
const canSubmit = message.trim().length > 0 && !disabled && !isLoading
const showAbortButton = isLoading && onAbort
// Render overlay content with highlighted mentions
const renderOverlayContent = useCallback(() => {
const contexts = contextManagement.selectedContexts
// Handle empty message
if (!message) {
return <span>{'\u00A0'}</span>
}
// If no contexts, render the message directly with proper newline handling
if (contexts.length === 0) {
// Add a zero-width space at the end if message ends with newline
// This ensures the newline is rendered and height is calculated correctly
const displayText = message.endsWith('\n') ? `${message}\u200B` : message
return <span>{displayText}</span>
}
const elements: React.ReactNode[] = []
const labels = contexts.map((c) => c.label).filter(Boolean)
// Build ranges for all mentions to highlight them including spaces
const ranges = mentionTokensWithContext.computeMentionRanges()
if (ranges.length === 0) {
@@ -682,11 +775,14 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i]
// Add text before mention
if (range.start > lastIndex) {
const before = message.slice(lastIndex, range.start)
elements.push(<span key={`text-${i}-${lastIndex}-${range.start}`}>{before}</span>)
}
// Add highlighted mention (including spaces)
// Use index + start + end to ensure unique keys even with duplicate contexts
const mentionText = message.slice(range.start, range.end)
elements.push(
<span
@@ -701,10 +797,12 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const tail = message.slice(lastIndex)
if (tail) {
// Add a zero-width space at the end if tail ends with newline
const displayTail = tail.endsWith('\n') ? `${tail}\u200B` : tail
elements.push(<span key={`tail-${lastIndex}`}>{displayTail}</span>)
}
// Ensure there's always something to render for height calculation
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
}, [message, contextManagement.selectedContexts, mentionTokensWithContext])
@@ -828,7 +926,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
mentionData={mentionData}
message={message}
insertHandlers={insertHandlers}
onFolderNavChange={setMentionFolderNav}
/>,
document.body
)}
@@ -841,7 +938,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
mentionMenu={mentionMenu}
message={message}
onSelectCommand={handleSlashCommandSelect}
onFolderNavChange={setSlashFolderNav}
/>,
document.body
)}

View File

@@ -1,149 +0,0 @@
import {
FOLDER_CONFIGS,
type MentionFolderId,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
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'
/**
* Gets the data array for a folder ID from mentionData.
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
* Returns any[] since item types vary by folder and are used with dynamic config.filterFn
*/
export function getFolderData(mentionData: MentionDataReturn, folderId: MentionFolderId): any[] {
const config = FOLDER_CONFIGS[folderId]
return (mentionData[config.dataKey as keyof MentionDataReturn] as any[]) || []
}
/**
* Gets the loading state for a folder ID from mentionData.
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
*/
export function getFolderLoading(
mentionData: MentionDataReturn,
folderId: MentionFolderId
): boolean {
const config = FOLDER_CONFIGS[folderId]
return mentionData[config.loadingKey as keyof MentionDataReturn] as boolean
}
/**
* Gets the ensure loaded function for a folder ID from mentionData.
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
*/
export function getFolderEnsureLoaded(
mentionData: MentionDataReturn,
folderId: MentionFolderId
): (() => Promise<void>) | undefined {
const config = FOLDER_CONFIGS[folderId]
if (!config.ensureLoadedKey) return undefined
return mentionData[config.ensureLoadedKey as keyof MentionDataReturn] as
| (() => Promise<void>)
| undefined
}
/**
* Extract specific ChatContext types for type-safe narrowing
*/
type PastChatContext = Extract<ChatContext, { kind: 'past_chat' }>
type WorkflowContext = Extract<ChatContext, { kind: 'workflow' }>
type CurrentWorkflowContext = Extract<ChatContext, { kind: 'current_workflow' }>
type BlocksContext = Extract<ChatContext, { kind: 'blocks' }>
type WorkflowBlockContext = Extract<ChatContext, { kind: 'workflow_block' }>
type KnowledgeContext = Extract<ChatContext, { kind: 'knowledge' }>
type TemplatesContext = Extract<ChatContext, { kind: 'templates' }>
type LogsContext = Extract<ChatContext, { kind: 'logs' }>
type SlashCommandContext = Extract<ChatContext, { kind: 'slash_command' }>
/**
* Checks if two contexts of the same kind are equal by their ID fields.
* Assumes c.kind === context.kind (must be checked before calling).
*/
export function areContextsEqual(c: ChatContext, context: ChatContext): boolean {
switch (c.kind) {
case 'past_chat': {
const ctx = context as PastChatContext
return c.chatId === ctx.chatId
}
case 'workflow': {
const ctx = context as WorkflowContext
return c.workflowId === ctx.workflowId
}
case 'current_workflow': {
const ctx = context as CurrentWorkflowContext
return c.workflowId === ctx.workflowId
}
case 'blocks': {
const ctx = context as BlocksContext
const existingIds = c.blockIds || []
const newIds = ctx.blockIds || []
return existingIds.some((id) => newIds.includes(id))
}
case 'workflow_block': {
const ctx = context as WorkflowBlockContext
return c.workflowId === ctx.workflowId && c.blockId === ctx.blockId
}
case 'knowledge': {
const ctx = context as KnowledgeContext
return c.knowledgeId === ctx.knowledgeId
}
case 'templates': {
const ctx = context as TemplatesContext
return c.templateId === ctx.templateId
}
case 'logs': {
const ctx = context as LogsContext
return c.executionId === ctx.executionId
}
case 'docs':
return true // Only one docs context allowed
case 'slash_command': {
const ctx = context as SlashCommandContext
return c.command === ctx.command
}
default:
return false
}
}
/**
* Removes a context from a list, returning a new filtered list.
*/
export function filterOutContext(
contexts: ChatContext[],
contextToRemove: ChatContext
): ChatContext[] {
return contexts.filter((c) => {
if (c.kind !== contextToRemove.kind) return true
return !areContextsEqual(c, contextToRemove)
})
}
/**
* Checks if a context already exists in selected contexts.
*
* The token system uses @label format, so we cannot have duplicate labels
* regardless of kind or ID differences.
*
* @param context - Context to check
* @param selectedContexts - Currently selected contexts
* @returns True if context already exists or label is already used
*/
export function isContextAlreadySelected(
context: ChatContext,
selectedContexts: ChatContext[]
): boolean {
return selectedContexts.some((c) => {
// CRITICAL: Check label collision FIRST
// The token system uses @label format, so we cannot have duplicate labels
// regardless of kind or ID differences
if (c.label && context.label && c.label === context.label) {
return true
}
// Secondary check: exact duplicate by ID fields
if (c.kind !== context.kind) return false
return areContextsEqual(c, context)
})
}

View File

@@ -1,4 +1,4 @@
import { memo, useMemo, useRef } from 'react'
import { memo, useCallback, useMemo, useRef } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
import { Button, Trash } from '@/components/emcn'
@@ -8,6 +8,7 @@ import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePanelEditorStore } from '@/stores/panel'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Global styles for subflow nodes (loop and parallel containers).
@@ -51,6 +52,8 @@ export interface SubflowNodeData {
isPreviewSelected?: boolean
kind: 'loop' | 'parallel'
name?: string
/** The ID of the group this subflow belongs to */
groupId?: string
}
/**
@@ -62,8 +65,9 @@ export interface SubflowNodeData {
* @returns Rendered subflow node component
*/
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
const { getNodes } = useReactFlow()
const { getNodes, setNodes } = useReactFlow()
const { collaborativeBatchRemoveBlocks } = useCollaborativeWorkflow()
const { getGroups } = useWorkflowStore()
const blockRef = useRef<HTMLDivElement>(null)
const currentWorkflow = useCurrentWorkflow()
@@ -140,10 +144,57 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
diffStatus === 'edited' && 'ring-[var(--warning)]'
)
/**
* Expands selection to include all group members on mouse down.
* This ensures that when a user starts dragging a subflow in a group,
* all other blocks in the group are also selected and will move together.
*/
const handleGroupMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only process left mouse button clicks
if (e.button !== 0) return
const groupId = data.groupId
if (!groupId) return
const groups = getGroups()
const group = groups[groupId]
if (!group || group.blockIds.length <= 1) return
const groupBlockIds = new Set(group.blockIds)
const allNodes = getNodes()
// Check if all group members are already selected
const allSelected = [...groupBlockIds].every((blockId) =>
allNodes.find((n) => n.id === blockId && n.selected)
)
if (allSelected) return
// Expand selection to include all group members
setNodes((nodes) =>
nodes.map((n) => {
const isInGroup = groupBlockIds.has(n.id)
const isThisBlock = n.id === id
return {
...n,
selected: isInGroup ? true : n.selected,
data: {
...n.data,
// Mark as grouped selection if in group but not the directly clicked block
isGroupedSelection: isInGroup && !isThisBlock && !n.selected,
},
}
})
)
},
[id, data.groupId, getNodes, setNodes, getGroups]
)
return (
<>
<SubflowNodeStyles />
<div className='group relative'>
<div className='group relative' onMouseDown={handleGroupMouseDown}>
<div
ref={blockRef}
onClick={() => setCurrentBlockId(id)}

View File

@@ -36,7 +36,6 @@ import {
Tooltip,
} from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { formatTimeWithSeconds } from '@/lib/core/utils/formatting'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
@@ -83,6 +82,18 @@ const COLUMN_WIDTHS = {
OUTPUT_PANEL: 'w-[400px]',
} as const
/**
* Color palette for run IDs - matching code syntax highlighting colors
*/
const RUN_ID_COLORS = [
{ text: '#4ADE80' }, // Green
{ text: '#F472B6' }, // Pink
{ text: '#60C5FF' }, // Blue
{ text: '#FF8533' }, // Orange
{ text: '#C084FC' }, // Purple
{ text: '#FCD34D' }, // Yellow
] as const
/**
* Shared styling constants
*/
@@ -172,6 +183,22 @@ const ToggleButton = ({
</Button>
)
/**
* Formats timestamp to H:MM:SS AM/PM TZ format
*/
const formatTimestamp = (timestamp: string): string => {
const date = new Date(timestamp)
const fullString = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true,
timeZoneName: 'short',
})
// Format: "5:54:55 PM PST" - return as is
return fullString
}
/**
* Truncates execution ID for display as run ID
*/
@@ -181,25 +208,16 @@ const formatRunId = (executionId?: string): string => {
}
/**
* Run ID colors
* Gets color for a run ID based on its index in the execution ID order map
*/
const RUN_ID_COLORS = [
'#4ADE80', // Green
'#F472B6', // Pink
'#60C5FF', // Blue
'#FF8533', // Orange
'#C084FC', // Purple
'#EAB308', // Yellow
'#2DD4BF', // Teal
'#FB7185', // Rose
] as const
/**
* Gets color for a run ID from the precomputed color map.
*/
const getRunIdColor = (executionId: string | undefined, colorMap: Map<string, string>) => {
const getRunIdColor = (
executionId: string | undefined,
executionIdOrderMap: Map<string, number>
) => {
if (!executionId) return null
return colorMap.get(executionId) ?? null
const colorIndex = executionIdOrderMap.get(executionId)
if (colorIndex === undefined) return null
return RUN_ID_COLORS[colorIndex % RUN_ID_COLORS.length]
}
/**
@@ -302,14 +320,12 @@ export function Terminal() {
} = useTerminalStore()
const isExpanded = useTerminalStore((state) => state.terminalHeight > NEAR_MIN_THRESHOLD)
const { activeWorkflowId } = useWorkflowRegistry()
const hasConsoleHydrated = useTerminalConsoleStore((state) => state._hasHydrated)
const workflowEntriesSelector = useCallback(
(state: { entries: ConsoleEntry[] }) =>
state.entries.filter((entry) => entry.workflowId === activeWorkflowId),
[activeWorkflowId]
)
const entriesFromStore = useTerminalConsoleStore(useShallow(workflowEntriesSelector))
const entries = hasConsoleHydrated ? entriesFromStore : []
const entries = useTerminalConsoleStore(useShallow(workflowEntriesSelector))
const clearWorkflowConsole = useTerminalConsoleStore((state) => state.clearWorkflowConsole)
const exportConsoleCSV = useTerminalConsoleStore((state) => state.exportConsoleCSV)
const [selectedEntry, setSelectedEntry] = useState<ConsoleEntry | null>(null)
@@ -446,52 +462,25 @@ export function Terminal() {
}, [allWorkflowEntries])
/**
* Track color offset - increments when old executions are trimmed
* so remaining executions keep their colors.
* Create stable execution ID to color index mapping based on order of first appearance.
* Once an execution ID is assigned a color index, it keeps that index.
* Uses all workflow entries to maintain consistent colors regardless of active filters.
*/
const colorStateRef = useRef<{ executionIds: string[]; offset: number }>({
executionIds: [],
offset: 0,
})
const executionIdOrderMap = useMemo(() => {
const orderMap = new Map<string, number>()
let colorIndex = 0
/**
* Compute colors for each execution ID using sequential assignment.
* Colors cycle through RUN_ID_COLORS based on position + offset.
* When old executions are trimmed, offset increments to preserve colors.
*/
const executionColorMap = useMemo(() => {
const currentIds: string[] = []
const seen = new Set<string>()
// Process entries in reverse order (oldest first) since entries array is newest-first
// Use allWorkflowEntries to ensure colors remain consistent when filters change
for (let i = allWorkflowEntries.length - 1; i >= 0; i--) {
const execId = allWorkflowEntries[i].executionId
if (execId && !seen.has(execId)) {
currentIds.push(execId)
seen.add(execId)
const entry = allWorkflowEntries[i]
if (entry.executionId && !orderMap.has(entry.executionId)) {
orderMap.set(entry.executionId, colorIndex)
colorIndex++
}
}
const { executionIds: prevIds, offset: prevOffset } = colorStateRef.current
let newOffset = prevOffset
if (prevIds.length > 0 && currentIds.length > 0) {
const currentOldest = currentIds[0]
if (prevIds[0] !== currentOldest) {
const trimmedCount = prevIds.indexOf(currentOldest)
if (trimmedCount > 0) {
newOffset = (prevOffset + trimmedCount) % RUN_ID_COLORS.length
}
}
}
const colorMap = new Map<string, string>()
for (let i = 0; i < currentIds.length; i++) {
const colorIndex = (newOffset + i) % RUN_ID_COLORS.length
colorMap.set(currentIds[i], RUN_ID_COLORS[colorIndex])
}
colorStateRef.current = { executionIds: currentIds, offset: newOffset }
return colorMap
return orderMap
}, [allWorkflowEntries])
/**
@@ -1137,7 +1126,7 @@ export function Terminal() {
<PopoverScrollArea style={{ maxHeight: '140px' }}>
{uniqueRunIds.map((runId, index) => {
const isSelected = filters.runIds.has(runId)
const runIdColor = getRunIdColor(runId, executionColorMap)
const runIdColor = getRunIdColor(runId, executionIdOrderMap)
return (
<PopoverItem
@@ -1148,7 +1137,7 @@ export function Terminal() {
>
<span
className='flex-1 font-mono text-[12px]'
style={{ color: runIdColor || '#D2D2D2' }}
style={{ color: runIdColor?.text || '#D2D2D2' }}
>
{formatRunId(runId)}
</span>
@@ -1344,7 +1333,7 @@ export function Terminal() {
const statusInfo = getStatusInfo(entry.success, entry.error)
const isSelected = selectedEntry?.id === entry.id
const BlockIcon = getBlockIcon(entry.blockType)
const runIdColor = getRunIdColor(entry.executionId, executionColorMap)
const runIdColor = getRunIdColor(entry.executionId, executionIdOrderMap)
return (
<div
@@ -1394,7 +1383,7 @@ export function Terminal() {
COLUMN_BASE_CLASS,
'truncate font-medium font-mono text-[12px]'
)}
style={{ color: runIdColor || '#D2D2D2' }}
style={{ color: runIdColor?.text || '#D2D2D2' }}
>
{formatRunId(entry.executionId)}
</span>
@@ -1420,7 +1409,7 @@ export function Terminal() {
ROW_TEXT_CLASS
)}
>
{formatTimeWithSeconds(new Date(entry.timestamp))}
{formatTimestamp(entry.timestamp)}
</span>
</div>
)

View File

@@ -107,7 +107,7 @@ export const ActionBar = memo(
return (
<div
className={cn(
'-top-[46px] absolute right-0',
'-top-[46px] absolute right-0 z-[100]',
'flex flex-row items-center',
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
'gap-[5px] rounded-[10px] bg-[var(--surface-4)] p-[5px]'

View File

@@ -12,6 +12,10 @@ export interface WorkflowBlockProps {
isPreview?: boolean
/** Whether this block is selected in preview mode */
isPreviewSelected?: boolean
/** Whether this block is selected as part of a group (not directly clicked) */
isGroupedSelection?: boolean
/** The ID of the group this block belongs to */
groupId?: string
subBlockValues?: Record<string, any>
blockState?: any
}

View File

@@ -1,7 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { Handle, type NodeProps, Position, useReactFlow, useUpdateNodeInternals } from 'reactflow'
import { Badge, Tooltip } from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { cn } from '@/lib/core/utils/cn'
@@ -915,8 +915,65 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const userPermissions = useUserPermissionsContext()
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
const isGroupedSelection = data.isGroupedSelection ?? false
// Get React Flow methods for group selection expansion
const { getNodes, setNodes } = useReactFlow()
const { getGroups } = useWorkflowStore()
/**
* Expands selection to include all group members on mouse down.
* This ensures that when a user starts dragging a block in a group,
* all other blocks in the group are also selected and will move together.
*/
const handleGroupMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only process left mouse button clicks
if (e.button !== 0) return
const groupId = data.groupId
if (!groupId) return
const groups = getGroups()
const group = groups[groupId]
if (!group || group.blockIds.length <= 1) return
const groupBlockIds = new Set(group.blockIds)
const allNodes = getNodes()
// Check if all group members are already selected
const allSelected = [...groupBlockIds].every((blockId) =>
allNodes.find((n) => n.id === blockId && n.selected)
)
if (allSelected) return
// Expand selection to include all group members
setNodes((nodes) =>
nodes.map((n) => {
const isInGroup = groupBlockIds.has(n.id)
const isThisBlock = n.id === id
return {
...n,
selected: isInGroup ? true : n.selected,
data: {
...n.data,
// Mark as grouped selection if in group but not the directly clicked block
isGroupedSelection: isInGroup && !isThisBlock && !n.selected,
},
}
})
)
},
[id, data.groupId, getNodes, setNodes, getGroups]
)
return (
<div className='group relative'>
<div
className='group relative'
data-grouped-selection={isGroupedSelection ? 'true' : undefined}
onMouseDown={handleGroupMouseDown}
>
<div
ref={contentRef}
onClick={handleClick}

View File

@@ -30,6 +30,7 @@ interface UseBlockVisualProps {
export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVisualProps) {
const isPreview = data.isPreview ?? false
const isPreviewSelected = data.isPreviewSelected ?? false
const isGroupedSelection = data.isGroupedSelection ?? false
const currentWorkflow = useCurrentWorkflow()
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -64,8 +65,18 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
diffStatus: isPreview ? undefined : diffStatus,
runPathStatus,
isPreviewSelection: isPreview && isPreviewSelected,
isGroupedSelection: !isPreview && isGroupedSelection,
}),
[isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview, isPreviewSelected]
[
isActive,
isPending,
isDeletedBlock,
diffStatus,
runPathStatus,
isPreview,
isPreviewSelected,
isGroupedSelection,
]
)
return {

View File

@@ -35,6 +35,7 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
const block = blocks[n.id]
const parentId = block?.data?.parentId
const parentType = parentId ? blocks[parentId]?.type : undefined
const groupId = block?.data?.groupId
return {
id: n.id,
type: block?.type || '',
@@ -42,6 +43,7 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
horizontalHandles: block?.horizontalHandles ?? false,
parentId,
parentType,
groupId,
}
}),
[blocks]
@@ -49,14 +51,22 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
/**
* Handle right-click on a node (block)
* If the node is part of a multiselection, include all selected nodes.
* If the node is not selected, just use that node.
*/
const handleNodeContextMenu = useCallback(
(event: React.MouseEvent, node: Node) => {
event.preventDefault()
event.stopPropagation()
const selectedNodes = getNodes().filter((n) => n.selected)
const nodesToUse = selectedNodes.some((n) => n.id === node.id) ? selectedNodes : [node]
// Get all currently selected nodes
const allNodes = getNodes()
const selectedNodes = allNodes.filter((n) => n.selected)
// If the right-clicked node is already selected, use all selected nodes
// Otherwise, just use the right-clicked node
const isNodeSelected = selectedNodes.some((n) => n.id === node.id)
const nodesToUse = isNodeSelected && selectedNodes.length > 0 ? selectedNodes : [node]
setPosition({ x: event.clientX, y: event.clientY })
setSelectedBlocks(nodesToBlockInfos(nodesToUse))

View File

@@ -11,6 +11,7 @@ export interface BlockRingOptions {
diffStatus: BlockDiffStatus
runPathStatus: BlockRunPathStatus
isPreviewSelection?: boolean
isGroupedSelection?: boolean
}
/**
@@ -21,8 +22,15 @@ export function getBlockRingStyles(options: BlockRingOptions): {
hasRing: boolean
ringClassName: string
} {
const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreviewSelection } =
options
const {
isActive,
isPending,
isDeletedBlock,
diffStatus,
runPathStatus,
isPreviewSelection,
isGroupedSelection,
} = options
const hasRing =
isActive ||
@@ -30,17 +38,24 @@ export function getBlockRingStyles(options: BlockRingOptions): {
diffStatus === 'new' ||
diffStatus === 'edited' ||
isDeletedBlock ||
!!runPathStatus
!!runPathStatus ||
!!isGroupedSelection
const ringClassName = cn(
// Grouped selection: more transparent ring for blocks selected as part of a group
// Using rgba with the brand-secondary color (#33b4ff) at 40% opacity
isGroupedSelection &&
!isActive &&
'ring-[2px] ring-[rgba(51,180,255,0.4)]',
// Preview selection: static blue ring (standard thickness, no animation)
isActive && isPreviewSelection && 'ring-[1.75px] ring-[var(--brand-secondary)]',
// Executing block: pulsing success ring with prominent thickness
isActive &&
!isPreviewSelection &&
!isGroupedSelection &&
'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
// Non-active states use standard ring utilities
!isActive && hasRing && 'ring-[1.75px]',
// Non-active states use standard ring utilities (except grouped selection which has its own)
!isActive && hasRing && !isGroupedSelection && 'ring-[1.75px]',
// Pending state: warning ring
!isActive && isPending && 'ring-[var(--warning)]',
// Deleted state (highest priority after active/pending)

View File

@@ -264,12 +264,14 @@ const WorkflowContent = React.memo(() => {
const canUndo = undoRedoStack.undo.length > 0
const canRedo = undoRedoStack.redo.length > 0
const { updateNodeDimensions, setDragStartPosition, getDragStartPosition } = useWorkflowStore(
useShallow((state) => ({
updateNodeDimensions: state.updateNodeDimensions,
setDragStartPosition: state.setDragStartPosition,
getDragStartPosition: state.getDragStartPosition,
}))
const { updateNodeDimensions, setDragStartPosition, getDragStartPosition, getGroups } =
useWorkflowStore(
useShallow((state) => ({
updateNodeDimensions: state.updateNodeDimensions,
setDragStartPosition: state.setDragStartPosition,
getDragStartPosition: state.getDragStartPosition,
getGroups: state.getGroups,
}))
)
const copilotCleanup = useCopilotStore((state) => state.cleanup)
@@ -356,14 +358,19 @@ const WorkflowContent = React.memo(() => {
/** Stores source node/handle info when a connection drag starts for drop-on-block detection. */
const connectionSourceRef = useRef<{ nodeId: string; handleId: string } | null>(null)
/** Tracks whether onConnect successfully handled the connection (ReactFlow pattern). */
const connectionCompletedRef = useRef(false)
/** Stores start positions for multi-node drag undo/redo recording. */
const multiNodeDragStartRef = useRef<Map<string, { x: number; y: number; parentId?: string }>>(
new Map()
)
/**
* Stores original positions and parentIds for nodes temporarily parented during group drag.
* Key: node ID, Value: { originalPosition, originalParentId }
*/
const groupDragTempParentsRef = useRef<
Map<string, { originalPosition: { x: number; y: number }; originalParentId?: string }>
>(new Map())
/** Stores node IDs to select on next derivedNodes sync (for paste/duplicate operations). */
const pendingSelectionRef = useRef<Set<string> | null>(null)
@@ -461,6 +468,8 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
collaborativeGroupBlocks,
collaborativeUngroupBlocks,
undo,
redo,
} = useCollaborativeWorkflow()
@@ -785,6 +794,35 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchToggleBlockHandles(blockIds)
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
const handleContextGroupBlocks = useCallback(() => {
const blockIds = contextMenuBlocks.map((block) => block.id)
if (blockIds.length >= 2) {
// Validate that all blocks share the same parent (or all have no parent)
// Blocks inside a subflow cannot be grouped with blocks outside that subflow
const parentIds = contextMenuBlocks.map((block) => block.parentId || null)
const uniqueParentIds = new Set(parentIds)
if (uniqueParentIds.size > 1) {
addNotification({
level: 'error',
message: 'Cannot group blocks from different subflows',
})
return
}
collaborativeGroupBlocks(blockIds)
}
}, [contextMenuBlocks, collaborativeGroupBlocks, addNotification])
const handleContextUngroupBlocks = useCallback(() => {
// Find the first block with a groupId
const groupedBlock = contextMenuBlocks.find((block) => block.groupId)
if (!groupedBlock?.groupId) return
// The block's groupId is the group we want to ungroup
// This is the direct group the block belongs to, which is the "top level" from the user's perspective
// (the most recently created group that contains this block)
collaborativeUngroupBlocks(groupedBlock.groupId)
}, [contextMenuBlocks, collaborativeUngroupBlocks])
const handleContextRemoveFromSubflow = useCallback(() => {
const blocksToRemove = contextMenuBlocks.filter(
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
@@ -1909,6 +1947,7 @@ const WorkflowContent = React.memo(() => {
name: block.name,
isActive,
isPending,
groupId: block.data?.groupId,
},
// Include dynamic dimensions for container resizing calculations (must match rendered size)
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
@@ -2063,16 +2102,56 @@ const WorkflowContent = React.memo(() => {
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
/** Handles node changes - applies changes and resolves parent-child selection conflicts.
* Also expands selection to include all group members when a grouped block is selected.
*/
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
setDisplayNodes((nds) => {
const updated = applyNodeChanges(changes, nds)
let updated = applyNodeChanges(changes, nds)
const hasSelectionChange = changes.some((c) => c.type === 'select')
return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated
if (hasSelectionChange) {
// Expand selection to include all group members
const groups = getGroups()
const selectedNodeIds = new Set(updated.filter((n) => n.selected).map((n) => n.id))
const groupsToInclude = new Set<string>()
// Find all groups that have at least one selected member
selectedNodeIds.forEach((nodeId) => {
const groupId = blocks[nodeId]?.data?.groupId
if (groupId && groups[groupId]) {
groupsToInclude.add(groupId)
}
})
// Add all blocks from those groups to the selection
if (groupsToInclude.size > 0) {
const expandedNodeIds = new Set(selectedNodeIds)
groupsToInclude.forEach((groupId) => {
const group = groups[groupId]
if (group) {
group.blockIds.forEach((blockId) => expandedNodeIds.add(blockId))
}
})
// Update nodes to include expanded selection
if (expandedNodeIds.size > selectedNodeIds.size) {
updated = updated.map((n) => ({
...n,
selected: expandedNodeIds.has(n.id) ? true : n.selected,
}))
}
}
// Resolve parent-child conflicts
updated = resolveParentChildSelectionConflicts(updated, blocks)
}
return updated
})
},
[blocks]
[blocks, getGroups]
)
/**
@@ -2217,8 +2296,7 @@ const WorkflowContent = React.memo(() => {
)
/**
* Captures the source handle when a connection drag starts.
* Resets connectionCompletedRef to track if onConnect handles this connection.
* Captures the source handle when a connection drag starts
*/
const onConnectStart = useCallback((_event: any, params: any) => {
const handleId: string | undefined = params?.handleId
@@ -2227,7 +2305,6 @@ const WorkflowContent = React.memo(() => {
nodeId: params?.nodeId,
handleId: params?.handleId,
}
connectionCompletedRef.current = false
}, [])
/** Handles new edge connections with container boundary validation. */
@@ -2288,7 +2365,6 @@ const WorkflowContent = React.memo(() => {
isInsideContainer: true,
},
})
connectionCompletedRef.current = true
return
}
@@ -2317,7 +2393,6 @@ const WorkflowContent = React.memo(() => {
}
: undefined,
})
connectionCompletedRef.current = true
}
},
[addEdge, getNodes, blocks]
@@ -2326,9 +2401,8 @@ const WorkflowContent = React.memo(() => {
/**
* Handles connection drag end. Detects if the edge was dropped over a block
* and automatically creates a connection to that block's target handle.
*
* Uses connectionCompletedRef to check if onConnect already handled this connection
* (ReactFlow pattern for distinguishing handle-to-handle vs handle-to-body drops).
* Only creates a connection if ReactFlow didn't already handle it (e.g., when
* dropping on the block body instead of a handle).
*/
const onConnectEnd = useCallback(
(event: MouseEvent | TouchEvent) => {
@@ -2340,12 +2414,6 @@ const WorkflowContent = React.memo(() => {
return
}
// If onConnect already handled this connection, skip (handle-to-handle case)
if (connectionCompletedRef.current) {
connectionSourceRef.current = null
return
}
// Get cursor position in flow coordinates
const clientPos = 'changedTouches' in event ? event.changedTouches[0] : event
const flowPosition = screenToFlowPosition({
@@ -2356,14 +2424,25 @@ const WorkflowContent = React.memo(() => {
// Find node under cursor
const targetNode = findNodeAtPosition(flowPosition)
// Create connection if valid target found (handle-to-body case)
// Create connection if valid target found AND edge doesn't already exist
// ReactFlow's onConnect fires first when dropping on a handle, so we check
// if that connection already exists to avoid creating duplicates.
// IMPORTANT: We must read directly from the store (not React state) because
// the store update from ReactFlow's onConnect may not have triggered a
// React re-render yet when this callback runs (typically 1-2ms later).
if (targetNode && targetNode.id !== source.nodeId) {
onConnect({
source: source.nodeId,
sourceHandle: source.handleId,
target: targetNode.id,
targetHandle: 'target',
})
const currentEdges = useWorkflowStore.getState().edges
const edgeAlreadyExists = currentEdges.some(
(e) => e.source === source.nodeId && e.target === targetNode.id
)
if (!edgeAlreadyExists) {
onConnect({
source: source.nodeId,
sourceHandle: source.handleId,
target: targetNode.id,
targetHandle: 'target',
})
}
}
connectionSourceRef.current = null
@@ -2533,9 +2612,55 @@ const WorkflowContent = React.memo(() => {
parentId: currentParentId,
})
// Capture all selected nodes' positions for multi-node undo/redo
// Expand selection to include all group members before capturing positions
const groups = getGroups()
const allNodes = getNodes()
const selectedNodes = allNodes.filter((n) => n.selected)
// Find the group of the dragged node
const draggedBlockGroupId = blocks[node.id]?.data?.groupId
// If the dragged node is in a group, expand selection to include all group members
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
const group = groups[draggedBlockGroupId]
const groupBlockIds = new Set(group.blockIds)
// Check if we need to expand selection
const currentSelectedIds = new Set(allNodes.filter((n) => n.selected).map((n) => n.id))
const needsExpansion = [...groupBlockIds].some((id) => !currentSelectedIds.has(id))
if (needsExpansion) {
setNodes((nodes) =>
nodes.map((n) => {
const isInGroup = groupBlockIds.has(n.id)
const isDirectlyDragged = n.id === node.id
return {
...n,
selected: isInGroup ? true : n.selected,
data: {
...n.data,
// Mark as grouped selection if in group but not the directly dragged node
isGroupedSelection: isInGroup && !isDirectlyDragged && !n.selected,
},
}
})
)
}
}
// Capture all selected nodes' positions for multi-node undo/redo
// Re-get nodes after potential selection expansion
const updatedNodes = getNodes()
const selectedNodes = updatedNodes.filter((n) => {
// Always include the dragged node
if (n.id === node.id) return true
// Include node if it's selected OR if it's in the same group as the dragged node
if (n.selected) return true
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
return groups[draggedBlockGroupId].blockIds.includes(n.id)
}
return false
})
multiNodeDragStartRef.current.clear()
selectedNodes.forEach((n) => {
const block = blocks[n.id]
@@ -2547,8 +2672,63 @@ const WorkflowContent = React.memo(() => {
})
}
})
// Set up temporary parent-child relationships for group members
// This leverages React Flow's built-in parent-child drag behavior
// BUT: Only do this if NOT all group members are already selected
// If all are selected, React Flow's native multiselect drag will handle it
groupDragTempParentsRef.current.clear()
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
const group = groups[draggedBlockGroupId]
if (group.blockIds.length > 1) {
// Check if all group members are already selected
const allGroupMembersSelected = group.blockIds.every((blockId) =>
updatedNodes.find((n) => n.id === blockId && n.selected)
)
// Only use temporary parent approach if NOT all members are selected
// (i.e., when click-and-dragging on an unselected grouped block)
if (!allGroupMembersSelected) {
// Get the dragged node's absolute position for calculating relative positions
const draggedNodeAbsPos = getNodeAbsolutePosition(node.id)
setNodes((nodes) =>
nodes.map((n) => {
// Skip the dragged node - it becomes the temporary parent
if (n.id === node.id) return n
// Only process nodes in the same group
if (group.blockIds.includes(n.id)) {
// Store original position and parentId for restoration later
groupDragTempParentsRef.current.set(n.id, {
originalPosition: { ...n.position },
originalParentId: n.parentId,
})
// Get this node's absolute position
const nodeAbsPos = getNodeAbsolutePosition(n.id)
// Calculate position relative to the dragged node
const relativePosition = {
x: nodeAbsPos.x - draggedNodeAbsPos.x,
y: nodeAbsPos.y - draggedNodeAbsPos.y,
}
return {
...n,
parentId: node.id, // Temporarily make this a child of the dragged node
position: relativePosition,
extent: undefined, // Remove extent constraint during drag
}
}
return n
})
)
}
}
}
},
[blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId]
[blocks, setDragStartPosition, getNodes, setNodes, getGroups, potentialParentId, setPotentialParentId, getNodeAbsolutePosition]
)
/** Handles node drag stop to establish parent-child relationships. */
@@ -2556,13 +2736,93 @@ const WorkflowContent = React.memo(() => {
(_event: React.MouseEvent, node: any) => {
clearDragHighlights()
// Compute absolute positions for group members before restoring parentIds
// We need to do this first because getNodes() will return stale data after setNodes
const computedGroupPositions = new Map<string, { x: number; y: number }>()
const draggedBlockGroupId = blocks[node.id]?.data?.groupId
if (groupDragTempParentsRef.current.size > 0) {
const draggedNodeAbsPos = getNodeAbsolutePosition(node.id)
const currentNodes = getNodes()
// Compute absolute positions for all temporarily parented nodes
for (const [nodeId, _tempData] of groupDragTempParentsRef.current) {
const nodeData = currentNodes.find((n) => n.id === nodeId)
if (nodeData) {
// The node's current position is relative to the dragged node
computedGroupPositions.set(nodeId, {
x: draggedNodeAbsPos.x + nodeData.position.x,
y: draggedNodeAbsPos.y + nodeData.position.y,
})
}
}
// Also store the dragged node's absolute position
computedGroupPositions.set(node.id, draggedNodeAbsPos)
// Restore temporary parent-child relationships
setNodes((nodes) =>
nodes.map((n) => {
const tempData = groupDragTempParentsRef.current.get(n.id)
if (tempData) {
const absolutePosition = computedGroupPositions.get(n.id) || n.position
return {
...n,
parentId: tempData.originalParentId,
position: absolutePosition,
extent: tempData.originalParentId ? ('parent' as const) : undefined,
}
}
return n
})
)
groupDragTempParentsRef.current.clear()
}
// Get all selected nodes to update their positions too
const allNodes = getNodes()
const selectedNodes = allNodes.filter((n) => n.selected)
let selectedNodes = allNodes.filter((n) => n.selected)
// If multiple nodes are selected, update all their positions
// If the dragged node is in a group, include all group members
if (draggedBlockGroupId) {
const groups = getGroups()
const group = groups[draggedBlockGroupId]
if (group && group.blockIds.length > 1) {
const groupBlockIds = new Set(group.blockIds)
// Include the dragged node and all group members that aren't already selected
const groupNodes = allNodes.filter(
(n) => groupBlockIds.has(n.id) && !selectedNodes.some((sn) => sn.id === n.id)
)
selectedNodes = [...selectedNodes, ...groupNodes]
// Also ensure the dragged node is included
if (!selectedNodes.some((n) => n.id === node.id)) {
const draggedNode = allNodes.find((n) => n.id === node.id)
if (draggedNode) {
selectedNodes = [...selectedNodes, draggedNode]
}
}
}
}
// If multiple nodes are selected (or in a group), update all their positions
if (selectedNodes.length > 1) {
const positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes)
// Use pre-computed positions for group members, otherwise use computeClampedPositionUpdates
let positionUpdates: Array<{ id: string; position: { x: number; y: number } }>
if (computedGroupPositions.size > 0) {
// For group drags, use the pre-computed absolute positions
positionUpdates = selectedNodes.map((n) => {
const precomputedPos = computedGroupPositions.get(n.id)
if (precomputedPos) {
return { id: n.id, position: precomputedPos }
}
// For non-group members, use current position
return { id: n.id, position: n.position }
})
} else {
positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes)
}
collaborativeBatchUpdatePositions(positionUpdates, {
previousPositions: multiNodeDragStartRef.current,
})
@@ -2846,6 +3106,7 @@ const WorkflowContent = React.memo(() => {
},
[
getNodes,
setNodes,
dragStartParentId,
potentialParentId,
updateNodeParent,
@@ -2864,6 +3125,7 @@ const WorkflowContent = React.memo(() => {
activeWorkflowId,
collaborativeBatchUpdatePositions,
collaborativeBatchUpdateParent,
getGroups,
]
)
@@ -3171,19 +3433,81 @@ const WorkflowContent = React.memo(() => {
/**
* Handles node click to select the node in ReactFlow.
* When clicking on a grouped block, also selects all other blocks in the group.
* Grouped blocks are marked with isGroupedSelection for different visual styling.
* Parent-child conflict resolution happens automatically in onNodesChange.
*/
const handleNodeClick = useCallback(
(event: React.MouseEvent, node: Node) => {
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
setNodes((nodes) =>
nodes.map((n) => ({
...n,
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
}))
)
const groups = getGroups()
// Track which nodes are directly clicked vs. group-expanded
const directlySelectedIds = new Set<string>()
setNodes((nodes) => {
// First, calculate the base selection
let updatedNodes = nodes.map((n) => {
const isDirectlySelected = isMultiSelect
? n.id === node.id
? true
: n.selected
: n.id === node.id
if (isDirectlySelected) {
directlySelectedIds.add(n.id)
}
return {
...n,
selected: isDirectlySelected,
data: {
...n.data,
isGroupedSelection: false, // Reset grouped selection flag
},
}
})
// Expand selection to include all group members
const selectedNodeIds = new Set(updatedNodes.filter((n) => n.selected).map((n) => n.id))
const groupsToInclude = new Set<string>()
// Find all groups that have at least one selected member
selectedNodeIds.forEach((nodeId) => {
const groupId = blocks[nodeId]?.data?.groupId
if (groupId && groups[groupId]) {
groupsToInclude.add(groupId)
}
})
// Add all blocks from those groups to the selection
if (groupsToInclude.size > 0) {
const expandedNodeIds = new Set(selectedNodeIds)
groupsToInclude.forEach((groupId) => {
const group = groups[groupId]
if (group) {
group.blockIds.forEach((blockId) => expandedNodeIds.add(blockId))
}
})
// Update nodes with expanded selection, marking group-expanded nodes
if (expandedNodeIds.size > selectedNodeIds.size) {
updatedNodes = updatedNodes.map((n) => {
const isGroupExpanded = expandedNodeIds.has(n.id) && !directlySelectedIds.has(n.id)
return {
...n,
selected: expandedNodeIds.has(n.id) ? true : n.selected,
data: {
...n.data,
isGroupedSelection: isGroupExpanded,
},
}
})
}
}
return updatedNodes
})
},
[setNodes]
[setNodes, blocks, getGroups]
)
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
@@ -3418,6 +3742,8 @@ const WorkflowContent = React.memo(() => {
onRemoveFromSubflow={handleContextRemoveFromSubflow}
onOpenEditor={handleContextOpenEditor}
onRename={handleContextRename}
onGroupBlocks={handleContextGroupBlocks}
onUngroupBlocks={handleContextUngroupBlocks}
hasClipboard={hasClipboard()}
showRemoveFromSubflow={contextMenuBlocks.some(
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')

View File

@@ -172,7 +172,7 @@ async function executeWebhookJobInternal(
const workflowVariables = (wfRows[0]?.variables as Record<string, any>) || {}
// Merge subblock states (matching workflow-execution pattern)
const mergedStates = mergeSubblockState(blocks)
const mergedStates = mergeSubblockState(blocks, {})
// Create serialized workflow
const serializer = new Serializer()

View File

@@ -98,23 +98,6 @@ export const A2ABlock: BlockConfig<A2AResponse> = {
condition: { field: 'operation', value: 'a2a_send_message' },
required: true,
},
{
id: 'data',
title: 'Data (JSON)',
type: 'code',
placeholder: '{\n "key": "value"\n}',
description: 'Structured data to include with the message (DataPart)',
condition: { field: 'operation', value: 'a2a_send_message' },
},
{
id: 'files',
title: 'Files',
type: 'file-upload',
placeholder: 'Upload files to send',
description: 'Files to include with the message (FilePart)',
condition: { field: 'operation', value: 'a2a_send_message' },
multiple: true,
},
{
id: 'taskId',
title: 'Task ID',
@@ -225,14 +208,6 @@ export const A2ABlock: BlockConfig<A2AResponse> = {
type: 'string',
description: 'Context ID for conversation continuity',
},
data: {
type: 'json',
description: 'Structured data to include with the message',
},
files: {
type: 'array',
description: 'Files to include with the message',
},
historyLength: {
type: 'number',
description: 'Number of history messages to include',

View File

@@ -26,8 +26,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
{ label: 'Send Message', id: 'send' },
{ label: 'Create Canvas', id: 'canvas' },
{ label: 'Read Messages', id: 'read' },
{ label: 'Get Message', id: 'get_message' },
{ label: 'Get Thread', id: 'get_thread' },
{ label: 'List Channels', id: 'list_channels' },
{ label: 'List Channel Members', id: 'list_members' },
{ label: 'List Users', id: 'list_users' },
@@ -318,68 +316,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
},
required: true,
},
// Get Message specific fields
{
id: 'getMessageTimestamp',
title: 'Message Timestamp',
type: 'short-input',
placeholder: 'Message timestamp (e.g., 1405894322.002768)',
condition: {
field: 'operation',
value: 'get_message',
},
required: true,
wandConfig: {
enabled: true,
prompt: `Extract or generate a Slack message timestamp from the user's input.
Slack message timestamps are in the format: XXXXXXXXXX.XXXXXX (seconds.microseconds since Unix epoch).
Examples:
- "1405894322.002768" -> 1405894322.002768 (already a valid timestamp)
- "thread_ts from the trigger" -> The user wants to reference a variable, output the original text
- A URL like "https://slack.com/archives/C123/p1405894322002768" -> Extract 1405894322.002768 (remove 'p' prefix, add decimal after 10th digit)
If the input looks like a reference to another block's output (contains < and >) or a variable, return it as-is.
Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
placeholder: 'Paste a Slack message URL or timestamp...',
generationType: 'timestamp',
},
},
// Get Thread specific fields
{
id: 'getThreadTimestamp',
title: 'Thread Timestamp',
type: 'short-input',
placeholder: 'Thread timestamp (thread_ts, e.g., 1405894322.002768)',
condition: {
field: 'operation',
value: 'get_thread',
},
required: true,
wandConfig: {
enabled: true,
prompt: `Extract or generate a Slack thread timestamp from the user's input.
Slack thread timestamps (thread_ts) are in the format: XXXXXXXXXX.XXXXXX (seconds.microseconds since Unix epoch).
Examples:
- "1405894322.002768" -> 1405894322.002768 (already a valid timestamp)
- "thread_ts from the trigger" -> The user wants to reference a variable, output the original text
- A URL like "https://slack.com/archives/C123/p1405894322002768" -> Extract 1405894322.002768 (remove 'p' prefix, add decimal after 10th digit)
If the input looks like a reference to another block's output (contains < and >) or a variable, return it as-is.
Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
placeholder: 'Paste a Slack thread URL or thread_ts...',
generationType: 'timestamp',
},
},
{
id: 'threadLimit',
title: 'Message Limit',
type: 'short-input',
placeholder: '100',
condition: {
field: 'operation',
value: 'get_thread',
},
},
{
id: 'oldest',
title: 'Oldest Timestamp',
@@ -494,8 +430,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
'slack_message',
'slack_canvas',
'slack_message_reader',
'slack_get_message',
'slack_get_thread',
'slack_list_channels',
'slack_list_members',
'slack_list_users',
@@ -514,10 +448,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
return 'slack_canvas'
case 'read':
return 'slack_message_reader'
case 'get_message':
return 'slack_get_message'
case 'get_thread':
return 'slack_get_thread'
case 'list_channels':
return 'slack_list_channels'
case 'list_members':
@@ -568,9 +498,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
includeDeleted,
userLimit,
userId,
getMessageTimestamp,
getThreadTimestamp,
threadLimit,
...rest
} = params
@@ -647,27 +574,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
break
}
case 'get_message':
if (!getMessageTimestamp) {
throw new Error('Message timestamp is required for get message operation')
}
baseParams.timestamp = getMessageTimestamp
break
case 'get_thread': {
if (!getThreadTimestamp) {
throw new Error('Thread timestamp is required for get thread operation')
}
baseParams.threadTs = getThreadTimestamp
if (threadLimit) {
const parsedLimit = Number.parseInt(threadLimit, 10)
if (!Number.isNaN(parsedLimit) && parsedLimit > 0) {
baseParams.limit = Math.min(parsedLimit, 200)
}
}
break
}
case 'list_channels': {
baseParams.includePrivate = includePrivate !== 'false'
baseParams.excludeArchived = true
@@ -773,14 +679,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
userLimit: { type: 'string', description: 'Maximum number of users to return' },
// Get User inputs
userId: { type: 'string', description: 'User ID to look up' },
// Get Message inputs
getMessageTimestamp: { type: 'string', description: 'Message timestamp to retrieve' },
// Get Thread inputs
getThreadTimestamp: { type: 'string', description: 'Thread timestamp to retrieve' },
threadLimit: {
type: 'string',
description: 'Maximum number of messages to return from thread',
},
},
outputs: {
// slack_message outputs (send operation)
@@ -808,24 +706,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
'Array of message objects with comprehensive properties: text, user, timestamp, reactions, threads, files, attachments, blocks, stars, pins, and edit history',
},
// slack_get_thread outputs (get_thread operation)
parentMessage: {
type: 'json',
description: 'The thread parent message with all properties',
},
replies: {
type: 'json',
description: 'Array of reply messages in the thread (excluding the parent)',
},
replyCount: {
type: 'number',
description: 'Number of replies returned in this response',
},
hasMore: {
type: 'boolean',
description: 'Whether there are more messages in the thread',
},
// slack_list_channels outputs (list_channels operation)
channels: {
type: 'json',

View File

@@ -1,207 +0,0 @@
import { TinybirdIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { TinybirdResponse } from '@/tools/tinybird/types'
export const TinybirdBlock: BlockConfig<TinybirdResponse> = {
type: 'tinybird',
name: 'Tinybird',
description: 'Send events and query data with Tinybird',
authMode: AuthMode.ApiKey,
longDescription:
'Interact with Tinybird using the Events API to stream JSON or NDJSON events, or use the Query API to execute SQL queries against Pipes and Data Sources.',
docsLink: 'https://www.tinybird.co/docs/api-reference',
category: 'tools',
bgColor: '#2EF598',
icon: TinybirdIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Send Events', id: 'tinybird_events' },
{ label: 'Query', id: 'tinybird_query' },
],
value: () => 'tinybird_events',
},
{
id: 'base_url',
title: 'Base URL',
type: 'short-input',
placeholder: 'https://api.tinybird.co',
required: true,
},
{
id: 'token',
title: 'API Token',
type: 'short-input',
placeholder: 'Enter your Tinybird API token',
password: true,
required: true,
},
// Send Events operation inputs
{
id: 'datasource',
title: 'Data Source',
type: 'short-input',
placeholder: 'my_events_datasource',
condition: { field: 'operation', value: 'tinybird_events' },
required: true,
},
{
id: 'data',
title: 'Data',
type: 'code',
placeholder:
'{"event": "click", "timestamp": "2024-01-01T12:00:00Z"}\n{"event": "view", "timestamp": "2024-01-01T12:00:01Z"}',
condition: { field: 'operation', value: 'tinybird_events' },
required: true,
},
{
id: 'format',
title: 'Format',
type: 'dropdown',
options: [
{ label: 'NDJSON (Newline-delimited JSON)', id: 'ndjson' },
{ label: 'JSON', id: 'json' },
],
value: () => 'ndjson',
condition: { field: 'operation', value: 'tinybird_events' },
},
{
id: 'compression',
title: 'Compression',
type: 'dropdown',
options: [
{ label: 'None', id: 'none' },
{ label: 'Gzip', id: 'gzip' },
],
value: () => 'none',
mode: 'advanced',
condition: { field: 'operation', value: 'tinybird_events' },
},
{
id: 'wait',
title: 'Wait for Acknowledgment',
type: 'switch',
value: () => 'false',
mode: 'advanced',
condition: { field: 'operation', value: 'tinybird_events' },
},
// Query operation inputs
{
id: 'query',
title: 'SQL Query',
type: 'code',
placeholder: 'SELECT * FROM my_pipe FORMAT JSON\nOR\nSELECT * FROM my_pipe FORMAT CSV',
condition: { field: 'operation', value: 'tinybird_query' },
required: true,
},
{
id: 'pipeline',
title: 'Pipeline Name',
type: 'short-input',
placeholder: 'my_pipe (optional)',
condition: { field: 'operation', value: 'tinybird_query' },
},
],
tools: {
access: ['tinybird_events', 'tinybird_query'],
config: {
tool: (params) => params.operation || 'tinybird_events',
params: (params) => {
const operation = params.operation || 'tinybird_events'
const result: Record<string, any> = {
base_url: params.base_url,
token: params.token,
}
if (operation === 'tinybird_events') {
// Send Events operation
if (!params.datasource) {
throw new Error('Data Source is required for Send Events operation')
}
if (!params.data) {
throw new Error('Data is required for Send Events operation')
}
result.datasource = params.datasource
result.data = params.data
result.format = params.format || 'ndjson'
result.compression = params.compression || 'none'
// Convert wait from string to boolean
// Convert wait from string to boolean
if (params.wait !== undefined) {
const waitValue =
typeof params.wait === 'string' ? params.wait.toLowerCase() : params.wait
result.wait = waitValue === 'true' || waitValue === true
}
} else if (operation === 'tinybird_query') {
// Query operation
if (!params.query) {
throw new Error('SQL Query is required for Query operation')
}
result.query = params.query
if (params.pipeline) {
result.pipeline = params.pipeline
}
}
return result
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
base_url: { type: 'string', description: 'Tinybird API base URL' },
// Send Events inputs
datasource: {
type: 'string',
description: 'Name of the Tinybird Data Source',
},
data: {
type: 'string',
description: 'Data to send as JSON or NDJSON string',
},
wait: { type: 'boolean', description: 'Wait for database acknowledgment' },
format: {
type: 'string',
description: 'Format of the events (ndjson or json)',
},
compression: {
type: 'string',
description: 'Compression format (none or gzip)',
},
// Query inputs
query: { type: 'string', description: 'SQL query to execute' },
pipeline: { type: 'string', description: 'Optional pipeline name' },
// Common
token: { type: 'string', description: 'Tinybird API Token' },
},
outputs: {
// Send Events outputs
successful_rows: {
type: 'number',
description: 'Number of rows successfully ingested',
},
quarantined_rows: {
type: 'number',
description: 'Number of rows quarantined (failed validation)',
},
// Query outputs
data: {
type: 'json',
description:
'Query result data. FORMAT JSON: array of objects. Other formats (CSV, TSV, etc.): raw text string.',
},
rows: { type: 'number', description: 'Number of rows returned (only with FORMAT JSON)' },
statistics: {
type: 'json',
description:
'Query execution statistics - elapsed time, rows read, bytes read (only with FORMAT JSON)',
},
},
}

View File

@@ -121,7 +121,6 @@ import { SupabaseBlock } from '@/blocks/blocks/supabase'
import { TavilyBlock } from '@/blocks/blocks/tavily'
import { TelegramBlock } from '@/blocks/blocks/telegram'
import { ThinkingBlock } from '@/blocks/blocks/thinking'
import { TinybirdBlock } from '@/blocks/blocks/tinybird'
import { TranslateBlock } from '@/blocks/blocks/translate'
import { TrelloBlock } from '@/blocks/blocks/trello'
import { TtsBlock } from '@/blocks/blocks/tts'
@@ -282,7 +281,6 @@ export const registry: Record<string, BlockConfig> = {
tavily: TavilyBlock,
telegram: TelegramBlock,
thinking: ThinkingBlock,
tinybird: TinybirdBlock,
translate: TranslateBlock,
trello: TrelloBlock,
twilio_sms: TwilioSMSBlock,
@@ -315,26 +313,6 @@ export const getBlock = (type: string): BlockConfig | undefined => {
return registry[normalized]
}
export const getLatestBlock = (baseType: string): BlockConfig | undefined => {
const normalized = baseType.replace(/-/g, '_')
const versionedKeys = Object.keys(registry).filter((key) => {
const match = key.match(new RegExp(`^${normalized}_v(\\d+)$`))
return match !== null
})
if (versionedKeys.length > 0) {
const sorted = versionedKeys.sort((a, b) => {
const versionA = Number.parseInt(a.match(/_v(\d+)$/)?.[1] || '0', 10)
const versionB = Number.parseInt(b.match(/_v(\d+)$/)?.[1] || '0', 10)
return versionB - versionA
})
return registry[sorted[0]]
}
return registry[normalized]
}
export const getBlockByToolName = (toolName: string): BlockConfig | undefined => {
return Object.values(registry).find((block) => block.tools?.access?.includes(toolName))
}

View File

@@ -1897,19 +1897,6 @@ export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function TinybirdIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'>
<rect x='0' y='0' width='24' height='24' fill='#2EF598' rx='6' />
<g transform='translate(2, 2) scale(0.833)'>
<path d='M25 2.64 17.195.5 14.45 6.635z' fill='#1E7F63' />
<path d='M17.535 17.77 10.39 15.215 6.195 25.5z' fill='#1E7F63' />
<path d='M0 11.495 17.535 17.77 20.41 4.36z' fill='#1F2437' />
</g>
</svg>
)
}
export function ClayIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 400 400'>

View File

@@ -378,10 +378,21 @@ function buildManualTriggerOutput(
}
function buildIntegrationTriggerOutput(
_finalInput: unknown,
finalInput: unknown,
workflowInput: unknown
): NormalizedBlockOutput {
return isPlainObject(workflowInput) ? (workflowInput as NormalizedBlockOutput) : {}
const base: NormalizedBlockOutput = isPlainObject(workflowInput)
? ({ ...(workflowInput as Record<string, unknown>) } as NormalizedBlockOutput)
: {}
if (isPlainObject(finalInput)) {
Object.assign(base, finalInput as Record<string, unknown>)
base.input = { ...(finalInput as Record<string, unknown>) }
} else {
base.input = finalInput
}
return mergeFilesIntoOutput(base, workflowInput)
}
function extractSubBlocks(block: SerializedBlock): Record<string, unknown> | undefined {

View File

@@ -22,7 +22,7 @@ import { useUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { filterNewEdges, mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
import { mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
@@ -242,10 +242,7 @@ export function useCollaborativeWorkflow() {
case EDGES_OPERATIONS.BATCH_ADD_EDGES: {
const { edges } = payload
if (Array.isArray(edges) && edges.length > 0) {
const newEdges = filterNewEdges(edges, workflowStore.edges)
if (newEdges.length > 0) {
workflowStore.batchAddEdges(newEdges)
}
workflowStore.batchAddEdges(edges)
}
break
}
@@ -427,6 +424,35 @@ export function useCollaborativeWorkflow() {
logger.info('Successfully applied batch-update-parent from remote user')
break
}
case BLOCKS_OPERATIONS.GROUP_BLOCKS: {
const { blockIds, groupId } = payload
logger.info('Received group-blocks from remote user', {
userId,
groupId,
blockCount: (blockIds || []).length,
})
if (blockIds && blockIds.length > 0 && groupId) {
workflowStore.groupBlocks(blockIds, groupId)
}
logger.info('Successfully applied group-blocks from remote user')
break
}
case BLOCKS_OPERATIONS.UNGROUP_BLOCKS: {
const { groupId } = payload
logger.info('Received ungroup-blocks from remote user', {
userId,
groupId,
})
if (groupId) {
workflowStore.ungroupBlocks(groupId)
}
logger.info('Successfully applied ungroup-blocks from remote user')
break
}
}
}
} catch (error) {
@@ -979,9 +1005,6 @@ export function useCollaborativeWorkflow() {
if (edges.length === 0) return false
const newEdges = filterNewEdges(edges, workflowStore.edges)
if (newEdges.length === 0) return false
const operationId = crypto.randomUUID()
addToQueue({
@@ -989,16 +1012,16 @@ export function useCollaborativeWorkflow() {
operation: {
operation: EDGES_OPERATIONS.BATCH_ADD_EDGES,
target: OPERATION_TARGETS.EDGES,
payload: { edges: newEdges },
payload: { edges },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
workflowStore.batchAddEdges(newEdges)
workflowStore.batchAddEdges(edges)
if (!options?.skipUndoRedo) {
newEdges.forEach((edge) => undoRedo.recordAddEdge(edge.id))
edges.forEach((edge) => undoRedo.recordAddEdge(edge.id))
}
return true
@@ -1590,6 +1613,83 @@ export function useCollaborativeWorkflow() {
]
)
const collaborativeGroupBlocks = useCallback(
(blockIds: string[]) => {
if (!isInActiveRoom()) {
logger.debug('Skipping group blocks - not in active workflow')
return null
}
if (blockIds.length < 2) {
logger.debug('Cannot group fewer than 2 blocks')
return null
}
const groupId = crypto.randomUUID()
const operationId = crypto.randomUUID()
addToQueue({
id: operationId,
operation: {
operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds, groupId },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
workflowStore.groupBlocks(blockIds, groupId)
undoRedo.recordGroupBlocks(blockIds, groupId)
logger.info('Grouped blocks collaboratively', { groupId, blockCount: blockIds.length })
return groupId
},
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo]
)
const collaborativeUngroupBlocks = useCallback(
(groupId: string) => {
if (!isInActiveRoom()) {
logger.debug('Skipping ungroup blocks - not in active workflow')
return []
}
const groups = workflowStore.getGroups()
const group = groups[groupId]
if (!group) {
logger.warn('Cannot ungroup - group not found', { groupId })
return []
}
const blockIds = [...group.blockIds]
const operationId = crypto.randomUUID()
addToQueue({
id: operationId,
operation: {
operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
target: OPERATION_TARGETS.BLOCKS,
payload: { groupId, blockIds },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
workflowStore.ungroupBlocks(groupId)
undoRedo.recordUngroupBlocks(groupId, blockIds)
logger.info('Ungrouped blocks collaboratively', { groupId, blockCount: blockIds.length })
return blockIds
},
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo]
)
return {
// Connection status
isConnected,
@@ -1628,6 +1728,10 @@ export function useCollaborativeWorkflow() {
collaborativeUpdateIterationCount,
collaborativeUpdateIterationCollection,
// Collaborative block group operations
collaborativeGroupBlocks,
collaborativeUngroupBlocks,
// Direct access to stores for non-collaborative operations
workflowStore,
subBlockStore,

View File

@@ -22,7 +22,9 @@ import {
type BatchToggleHandlesOperation,
type BatchUpdateParentOperation,
createOperationEntry,
type GroupBlocksOperation,
runWithUndoRedoRecordingSuspended,
type UngroupBlocksOperation,
type UpdateParentOperation,
useUndoRedoStore,
} from '@/stores/undo-redo'
@@ -874,6 +876,46 @@ export function useUndoRedo() {
})
break
}
case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: {
// Undoing group = ungroup (inverse is ungroup operation)
const inverseOp = entry.inverse as unknown as UngroupBlocksOperation
const { groupId } = inverseOp.data
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
target: OPERATION_TARGETS.BLOCKS,
payload: { groupId, blockIds: inverseOp.data.blockIds },
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.ungroupBlocks(groupId)
logger.debug('Undid group blocks', { groupId })
break
}
case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: {
// Undoing ungroup = re-group (inverse is group operation)
const inverseOp = entry.inverse as unknown as GroupBlocksOperation
const { groupId, blockIds } = inverseOp.data
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
target: OPERATION_TARGETS.BLOCKS,
payload: { groupId, blockIds },
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.groupBlocks(blockIds, groupId)
logger.debug('Undid ungroup blocks', { groupId })
break
}
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
const applyDiffInverse = entry.inverse as any
const { baselineSnapshot } = applyDiffInverse.data
@@ -1482,6 +1524,46 @@ export function useUndoRedo() {
})
break
}
case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: {
// Redo group = group again
const groupOp = entry.operation as GroupBlocksOperation
const { groupId, blockIds } = groupOp.data
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
target: OPERATION_TARGETS.BLOCKS,
payload: { groupId, blockIds },
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.groupBlocks(blockIds, groupId)
logger.debug('Redid group blocks', { groupId })
break
}
case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: {
// Redo ungroup = ungroup again
const ungroupOp = entry.operation as UngroupBlocksOperation
const { groupId } = ungroupOp.data
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
target: OPERATION_TARGETS.BLOCKS,
payload: { groupId, blockIds: ungroupOp.data.blockIds },
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.ungroupBlocks(groupId)
logger.debug('Redid ungroup blocks', { groupId })
break
}
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
// Redo apply-diff means re-applying the proposed state with diff markers
const applyDiffOp = entry.operation as any
@@ -1793,6 +1875,66 @@ export function useUndoRedo() {
[activeWorkflowId, userId, undoRedoStore]
)
const recordGroupBlocks = useCallback(
(blockIds: string[], groupId: string) => {
if (!activeWorkflowId || blockIds.length === 0) return
const operation: GroupBlocksOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { groupId, blockIds },
}
const inverse: UngroupBlocksOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { groupId, blockIds },
}
const entry = createOperationEntry(operation, inverse)
undoRedoStore.push(activeWorkflowId, userId, entry)
logger.debug('Recorded group blocks', { groupId, blockCount: blockIds.length })
},
[activeWorkflowId, userId, undoRedoStore]
)
const recordUngroupBlocks = useCallback(
(groupId: string, blockIds: string[], parentGroupId?: string) => {
if (!activeWorkflowId || blockIds.length === 0) return
const operation: UngroupBlocksOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { groupId, blockIds, parentGroupId },
}
const inverse: GroupBlocksOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { groupId, blockIds },
}
const entry = createOperationEntry(operation, inverse)
undoRedoStore.push(activeWorkflowId, userId, entry)
logger.debug('Recorded ungroup blocks', { groupId, blockCount: blockIds.length })
},
[activeWorkflowId, userId, undoRedoStore]
)
return {
recordBatchAddBlocks,
recordBatchRemoveBlocks,
@@ -1806,6 +1948,8 @@ export function useUndoRedo() {
recordApplyDiff,
recordAcceptDiff,
recordRejectDiff,
recordGroupBlocks,
recordUngroupBlocks,
undo,
redo,
getStackSizes,

View File

@@ -36,10 +36,9 @@ class ApiKeyInterceptor implements CallInterceptor {
/**
* Create an A2A client from an agent URL with optional API key authentication
*
* Supports both standard A2A agents (agent card at /.well-known/agent.json)
* and Sim Studio agents (agent card at root URL via GET).
*
* Tries standard path first, falls back to root URL for compatibility.
* The agent URL should be the full endpoint URL (e.g., /api/a2a/serve/{agentId}).
* We pass an empty path to createFromUrl so it uses the URL directly for agent card
* discovery (GET on the URL) instead of appending .well-known/agent-card.json.
*/
export async function createA2AClient(agentUrl: string, apiKey?: string): Promise<Client> {
const factoryOptions = apiKey
@@ -50,18 +49,6 @@ export async function createA2AClient(agentUrl: string, apiKey?: string): Promis
})
: ClientFactoryOptions.default
const factory = new ClientFactory(factoryOptions)
// Try standard A2A path first (/.well-known/agent.json)
try {
return await factory.createFromUrl(agentUrl, '/.well-known/agent.json')
} catch (standardError) {
logger.debug('Standard agent card path failed, trying root URL', {
agentUrl,
error: standardError instanceof Error ? standardError.message : String(standardError),
})
}
// Fall back to root URL (Sim Studio compatibility)
return factory.createFromUrl(agentUrl, '')
}

View File

@@ -656,7 +656,7 @@ export const auth = betterAuth({
const now = new Date()
return {
id: `${profile.id.toString()}-${crypto.randomUUID()}`,
id: profile.id.toString(),
name: profile.name || profile.login,
email: profile.email,
image: profile.avatar_url,
@@ -962,7 +962,7 @@ export const auth = betterAuth({
const now = new Date()
return {
id: `${uniqueId}-${crypto.randomUUID()}`,
id: uniqueId,
name: 'Wealthbox User',
email: `${uniqueId}@wealthbox.user`,
emailVerified: false,
@@ -1016,7 +1016,7 @@ export const auth = betterAuth({
const user = data.data
return {
id: `${user.id.toString()}-${crypto.randomUUID()}`,
id: user.id.toString(),
name: user.name,
email: user.email,
emailVerified: user.activated,
@@ -1108,7 +1108,7 @@ export const auth = betterAuth({
})
return {
id: `${data.user_id || data.hub_id.toString()}-${crypto.randomUUID()}`,
id: data.user_id || data.hub_id.toString(),
name: data.user || 'HubSpot User',
email: data.user || `hubspot-${data.hub_id}@hubspot.com`,
emailVerified: true,
@@ -1162,7 +1162,7 @@ export const auth = betterAuth({
const data = await response.json()
return {
id: `${data.user_id || data.sub}-${crypto.randomUUID()}`,
id: data.user_id || data.sub,
name: data.name || 'Salesforce User',
email: data.email || `salesforce-${data.user_id}@salesforce.com`,
emailVerified: data.email_verified || true,
@@ -1221,7 +1221,7 @@ export const auth = betterAuth({
const now = new Date()
return {
id: `${profile.data.id}-${crypto.randomUUID()}`,
id: profile.data.id,
name: profile.data.name || 'X User',
email: `${profile.data.username}@x.com`,
image: profile.data.profile_image_url,
@@ -1295,7 +1295,7 @@ export const auth = betterAuth({
const now = new Date()
return {
id: `${profile.account_id}-${crypto.randomUUID()}`,
id: profile.account_id,
name: profile.name || profile.display_name || 'Confluence User',
email: profile.email || `${profile.account_id}@atlassian.com`,
image: profile.picture || undefined,
@@ -1406,7 +1406,7 @@ export const auth = betterAuth({
const now = new Date()
return {
id: `${profile.account_id}-${crypto.randomUUID()}`,
id: profile.account_id,
name: profile.name || profile.display_name || 'Jira User',
email: profile.email || `${profile.account_id}@atlassian.com`,
image: profile.picture || undefined,
@@ -1456,7 +1456,7 @@ export const auth = betterAuth({
const now = new Date()
return {
id: `${data.id}-${crypto.randomUUID()}`,
id: data.id,
name: data.email ? data.email.split('@')[0] : 'Airtable User',
email: data.email || `${data.id}@airtable.user`,
emailVerified: !!data.email,
@@ -1505,7 +1505,7 @@ export const auth = betterAuth({
const now = new Date()
return {
id: `${profile.bot?.owner?.user?.id || profile.id}-${crypto.randomUUID()}`,
id: profile.bot?.owner?.user?.id || profile.id,
name: profile.name || profile.bot?.owner?.user?.name || 'Notion User',
email: profile.person?.email || `${profile.id}@notion.user`,
emailVerified: !!profile.person?.email,
@@ -1572,7 +1572,7 @@ export const auth = betterAuth({
const now = new Date()
return {
id: `${data.id}-${crypto.randomUUID()}`,
id: data.id,
name: data.name || 'Reddit User',
email: `${data.name}@reddit.user`,
image: data.icon_img || undefined,
@@ -1644,7 +1644,7 @@ export const auth = betterAuth({
const viewer = data.viewer
return {
id: `${viewer.id}-${crypto.randomUUID()}`,
id: viewer.id,
email: viewer.email,
name: viewer.name,
emailVerified: true,
@@ -1707,7 +1707,7 @@ export const auth = betterAuth({
const data = await response.json()
return {
id: `${data.account_id}-${crypto.randomUUID()}`,
id: data.account_id,
email: data.email,
name: data.name?.display_name || data.email,
emailVerified: data.email_verified || false,
@@ -1758,7 +1758,7 @@ export const auth = betterAuth({
const now = new Date()
return {
id: `${profile.gid}-${crypto.randomUUID()}`,
id: profile.gid,
name: profile.name || 'Asana User',
email: profile.email || `${profile.gid}@asana.user`,
image: profile.photo?.image_128x128 || undefined,
@@ -1834,7 +1834,7 @@ export const auth = betterAuth({
logger.info('Slack credential identifier', { teamId, userId, uniqueId, teamName })
return {
id: `${uniqueId}-${crypto.randomUUID()}`,
id: uniqueId,
name: teamName,
email: `${teamId}-${userId}@slack.bot`,
emailVerified: false,
@@ -1884,7 +1884,7 @@ export const auth = betterAuth({
const uniqueId = `webflow-${userId}`
return {
id: `${uniqueId}-${crypto.randomUUID()}`,
id: uniqueId,
name: data.user_name || 'Webflow User',
email: `${uniqueId.replace(/[^a-zA-Z0-9]/g, '')}@webflow.user`,
emailVerified: false,
@@ -1931,7 +1931,7 @@ export const auth = betterAuth({
const profile = await response.json()
return {
id: `${profile.sub}-${crypto.randomUUID()}`,
id: profile.sub,
name: profile.name || 'LinkedIn User',
email: profile.email || `${profile.sub}@linkedin.user`,
emailVerified: profile.email_verified || true,
@@ -1993,7 +1993,7 @@ export const auth = betterAuth({
const profile = await response.json()
return {
id: `${profile.id}-${crypto.randomUUID()}`,
id: profile.id,
name:
`${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Zoom User',
email: profile.email || `${profile.id}@zoom.user`,
@@ -2060,7 +2060,7 @@ export const auth = betterAuth({
const profile = await response.json()
return {
id: `${profile.id}-${crypto.randomUUID()}`,
id: profile.id,
name: profile.display_name || 'Spotify User',
email: profile.email || `${profile.id}@spotify.user`,
emailVerified: true,
@@ -2108,7 +2108,7 @@ export const auth = betterAuth({
const profile = await response.json()
return {
id: `${profile.ID?.toString() || profile.id?.toString()}-${crypto.randomUUID()}`,
id: profile.ID?.toString() || profile.id?.toString(),
name: profile.display_name || profile.username || 'WordPress User',
email: profile.email || `${profile.username}@wordpress.com`,
emailVerified: profile.email_verified || false,

View File

@@ -18,7 +18,7 @@ export const DEFAULT_ENTERPRISE_TIER_COST_LIMIT = 200
* Base charge applied to every workflow execution
* This charge is applied regardless of whether the workflow uses AI models
*/
export const BASE_EXECUTION_CHARGE = 0.005
export const BASE_EXECUTION_CHARGE = 0.001
/**
* Fixed cost for search tool invocation (in dollars)

View File

@@ -7,6 +7,7 @@
export function getTimezoneAbbreviation(timezone: string, date: Date = new Date()): string {
if (timezone === 'UTC') return 'UTC'
// Common timezone mappings
const timezoneMap: Record<string, { standard: string; daylight: string }> = {
'America/Los_Angeles': { standard: 'PST', daylight: 'PDT' },
'America/Denver': { standard: 'MST', daylight: 'MDT' },
@@ -19,22 +20,30 @@ export function getTimezoneAbbreviation(timezone: string, date: Date = new Date(
'Asia/Singapore': { standard: 'SGT', daylight: 'SGT' }, // Singapore doesn't use DST
}
// If we have a mapping for this timezone
if (timezone in timezoneMap) {
// January 1 is guaranteed to be standard time in northern hemisphere
// July 1 is guaranteed to be daylight time in northern hemisphere (if observed)
const januaryDate = new Date(date.getFullYear(), 0, 1)
const julyDate = new Date(date.getFullYear(), 6, 1)
// Get offset in January (standard time)
const januaryFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
})
// Get offset in July (likely daylight time)
const julyFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
})
// If offsets are different, timezone observes DST
const isDSTObserved = januaryFormatter.format(januaryDate) !== julyFormatter.format(julyDate)
// If DST is observed, check if current date is in DST by comparing its offset
// with January's offset (standard time)
if (isDSTObserved) {
const currentFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
@@ -45,9 +54,11 @@ export function getTimezoneAbbreviation(timezone: string, date: Date = new Date(
return isDST ? timezoneMap[timezone].daylight : timezoneMap[timezone].standard
}
// If DST is not observed, always use standard
return timezoneMap[timezone].standard
}
// For unknown timezones, use full IANA name
return timezone
}
@@ -68,6 +79,7 @@ export function formatDateTime(date: Date, timezone?: string): string {
timeZone: timezone || undefined,
})
// If timezone is provided, add a friendly timezone abbreviation
if (timezone) {
const tzAbbr = getTimezoneAbbreviation(timezone, date)
return `${formattedDate} ${tzAbbr}`
@@ -102,40 +114,6 @@ export function formatTime(date: Date): string {
})
}
/**
* Format a time with seconds and timezone
* @param date - The date to format
* @param includeTimezone - Whether to include the timezone abbreviation
* @returns A formatted time string in the format "h:mm:ss AM/PM TZ"
*/
export function formatTimeWithSeconds(date: Date, includeTimezone = true): string {
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true,
timeZoneName: includeTimezone ? 'short' : undefined,
})
}
/**
* Format an ISO timestamp into a compact format for UI display
* @param iso - ISO timestamp string
* @returns A formatted string in "MM-DD HH:mm" format
*/
export function formatCompactTimestamp(iso: string): string {
try {
const d = new Date(iso)
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${mm}-${dd} ${hh}:${min}`
} catch {
return iso
}
}
/**
* Format a duration in milliseconds to a human-readable format
* @param durationMs - The duration in milliseconds

View File

@@ -8,7 +8,7 @@ import {
// Mock the billing constants
vi.mock('@/lib/billing/constants', () => ({
BASE_EXECUTION_CHARGE: 0.005,
BASE_EXECUTION_CHARGE: 0.001,
}))
vi.mock('@sim/logger', () => loggerMock)
@@ -148,7 +148,7 @@ describe('createEnvironmentObject', () => {
})
describe('calculateCostSummary', () => {
const BASE_EXECUTION_CHARGE = 0.005
const BASE_EXECUTION_CHARGE = 0.001
test('should return base execution charge for empty trace spans', () => {
const result = calculateCostSummary([])

View File

@@ -1,4 +1,4 @@
import { getLatestBlock } from '@/blocks/registry'
import { getBlock } from '@/blocks/registry'
import { getAllTriggers } from '@/triggers'
export interface TriggerOption {
@@ -49,13 +49,22 @@ export function getTriggerOptions(): TriggerOption[] {
continue
}
const block = getLatestBlock(provider)
const block = getBlock(provider)
providerMap.set(provider, {
value: provider,
label: block?.name || formatProviderName(provider),
color: block?.bgColor || '#6b7280',
})
if (block) {
providerMap.set(provider, {
value: provider,
label: block.name, // Use block's display name (e.g., "Slack", "GitHub")
color: block.bgColor || '#6b7280', // Use block's hex color, fallback to gray
})
} else {
const label = formatProviderName(provider)
providerMap.set(provider, {
value: provider,
label,
color: '#6b7280', // gray fallback
})
}
}
const integrationOptions = Array.from(providerMap.values()).sort((a, b) =>

File diff suppressed because it is too large Load Diff

View File

@@ -16,9 +16,61 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('AutoLayout')
/** Default block dimensions for layout calculations */
const DEFAULT_BLOCK_WIDTH = 250
const DEFAULT_BLOCK_HEIGHT = 100
/**
* Identifies groups from blocks and calculates their bounding boxes.
* Returns a map of groupId to group info including bounding box and member block IDs.
*/
function identifyGroups(blocks: Record<string, BlockState>): Map<
string,
{
blockIds: string[]
bounds: { minX: number; minY: number; maxX: number; maxY: number }
}
> {
const groups = new Map<
string,
{
blockIds: string[]
bounds: { minX: number; minY: number; maxX: number; maxY: number }
}
>()
// Group blocks by their groupId
for (const [blockId, block] of Object.entries(blocks)) {
const groupId = block.data?.groupId
if (!groupId) continue
if (!groups.has(groupId)) {
groups.set(groupId, {
blockIds: [],
bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
})
}
const group = groups.get(groupId)!
group.blockIds.push(blockId)
// Update bounding box
const blockWidth = block.data?.width ?? DEFAULT_BLOCK_WIDTH
const blockHeight = block.data?.height ?? block.height ?? DEFAULT_BLOCK_HEIGHT
group.bounds.minX = Math.min(group.bounds.minX, block.position.x)
group.bounds.minY = Math.min(group.bounds.minY, block.position.y)
group.bounds.maxX = Math.max(group.bounds.maxX, block.position.x + blockWidth)
group.bounds.maxY = Math.max(group.bounds.maxY, block.position.y + blockHeight)
}
return groups
}
/**
* Applies automatic layout to all blocks in a workflow.
* Positions blocks in layers based on their connections (edges).
* Groups are treated as single units and laid out together.
*/
export function applyAutoLayout(
blocks: Record<string, BlockState>,
@@ -36,6 +88,11 @@ export function applyAutoLayout(
const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_SPACING
const verticalSpacing = options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING
// Identify groups and their bounding boxes
const groups = identifyGroups(blocksCopy)
logger.info('Identified block groups for layout', { groupCount: groups.size })
// Pre-calculate container dimensions by laying out their children (bottom-up)
// This ensures accurate widths/heights before root-level layout
prepareContainerDimensions(
@@ -49,19 +106,112 @@ export function applyAutoLayout(
const { root: rootBlockIds } = getBlocksByParent(blocksCopy)
const layoutRootIds = filterLayoutEligibleBlockIds(rootBlockIds, blocksCopy)
const rootBlocks: Record<string, BlockState> = {}
for (const id of layoutRootIds) {
rootBlocks[id] = blocksCopy[id]
// For groups, we need to:
// 1. Create virtual blocks representing each group
// 2. Replace grouped blocks with their group's virtual block
// 3. Layout the virtual blocks + ungrouped blocks
// 4. Apply position deltas to grouped blocks
// Track which blocks are in groups at root level
const groupedRootBlockIds = new Set<string>()
const groupRepresentatives = new Map<string, string>() // groupId -> representative blockId
// Store ORIGINAL positions of all grouped blocks before any modifications
const originalBlockPositions = new Map<string, { x: number; y: number }>()
for (const [_groupId, group] of groups) {
for (const blockId of group.blockIds) {
if (blocksCopy[blockId]) {
originalBlockPositions.set(blockId, { ...blocksCopy[blockId].position })
}
}
}
const rootEdges = edges.filter(
(edge) => layoutRootIds.includes(edge.source) && layoutRootIds.includes(edge.target)
)
for (const [groupId, group] of groups) {
// Find if any blocks in this group are at root level
const rootGroupBlocks = group.blockIds.filter((id) => layoutRootIds.includes(id))
if (rootGroupBlocks.length > 0) {
// Mark all blocks in this group as grouped
for (const blockId of rootGroupBlocks) {
groupedRootBlockIds.add(blockId)
}
// Use the first block as the group's representative for layout
const representativeId = rootGroupBlocks[0]
groupRepresentatives.set(groupId, representativeId)
// Update the representative block's dimensions to match the group's bounding box
const bounds = group.bounds
const groupWidth = bounds.maxX - bounds.minX
const groupHeight = bounds.maxY - bounds.minY
blocksCopy[representativeId] = {
...blocksCopy[representativeId],
data: {
...blocksCopy[representativeId].data,
width: groupWidth,
height: groupHeight,
},
// Position at the group's top-left corner
position: { x: bounds.minX, y: bounds.minY },
}
}
}
// Build the blocks to layout: ungrouped blocks + group representatives
const rootBlocks: Record<string, BlockState> = {}
for (const id of layoutRootIds) {
// Skip grouped blocks that aren't representatives
if (groupedRootBlockIds.has(id)) {
// Only include if this is a group representative
for (const [groupId, repId] of groupRepresentatives) {
if (repId === id) {
rootBlocks[id] = blocksCopy[id]
break
}
}
} else {
rootBlocks[id] = blocksCopy[id]
}
}
// Remap edges: edges involving grouped blocks should connect to the representative
const blockToGroup = new Map<string, string>() // blockId -> groupId
for (const [groupId, group] of groups) {
for (const blockId of group.blockIds) {
blockToGroup.set(blockId, groupId)
}
}
const layoutBlockIds = new Set(Object.keys(rootBlocks))
const rootEdges = edges
.map((edge) => {
let source = edge.source
let target = edge.target
// Remap source if it's in a group
const sourceGroupId = blockToGroup.get(source)
if (sourceGroupId && groupRepresentatives.has(sourceGroupId)) {
source = groupRepresentatives.get(sourceGroupId)!
}
// Remap target if it's in a group
const targetGroupId = blockToGroup.get(target)
if (targetGroupId && groupRepresentatives.has(targetGroupId)) {
target = groupRepresentatives.get(targetGroupId)!
}
return { ...edge, source, target }
})
.filter((edge) => layoutBlockIds.has(edge.source) && layoutBlockIds.has(edge.target))
// Calculate subflow depths before laying out root blocks
// This ensures blocks connected to subflow ends are positioned correctly
const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers)
// Store old positions for groups to calculate deltas
const oldGroupPositions = new Map<string, { x: number; y: number }>()
for (const [groupId, repId] of groupRepresentatives) {
oldGroupPositions.set(groupId, { ...blocksCopy[repId].position })
}
if (Object.keys(rootBlocks).length > 0) {
const { nodes } = layoutBlocksCore(rootBlocks, rootEdges, {
isContainer: false,
@@ -69,15 +219,49 @@ export function applyAutoLayout(
subflowDepths,
})
// Apply positions to ungrouped blocks and group representatives
for (const node of nodes.values()) {
blocksCopy[node.id].position = node.position
}
// For each group, calculate the delta and apply to ALL blocks in the group
for (const [groupId, repId] of groupRepresentatives) {
const oldGroupTopLeft = oldGroupPositions.get(groupId)!
const newGroupTopLeft = blocksCopy[repId].position
const deltaX = newGroupTopLeft.x - oldGroupTopLeft.x
const deltaY = newGroupTopLeft.y - oldGroupTopLeft.y
const group = groups.get(groupId)!
// Apply delta to ALL blocks in the group using their ORIGINAL positions
for (const blockId of group.blockIds) {
if (layoutRootIds.includes(blockId)) {
const originalPos = originalBlockPositions.get(blockId)
if (originalPos) {
blocksCopy[blockId].position = {
x: originalPos.x + deltaX,
y: originalPos.y + deltaY,
}
}
}
}
// Restore the representative's original dimensions
const originalBlock = blocks[repId]
if (originalBlock) {
blocksCopy[repId].data = {
...blocksCopy[repId].data,
width: originalBlock.data?.width,
height: originalBlock.data?.height,
}
}
}
}
layoutContainers(blocksCopy, edges, options)
logger.info('Auto layout completed successfully', {
blockCount: Object.keys(blocksCopy).length,
groupCount: groups.size,
})
return {

View File

@@ -26,9 +26,53 @@ export interface TargetedLayoutOptions extends LayoutOptions {
horizontalSpacing?: number
}
/**
* Identifies block groups from the blocks' groupId data.
* Returns a map of groupId to array of block IDs in that group.
*/
function identifyBlockGroups(blocks: Record<string, BlockState>): Map<string, string[]> {
const groups = new Map<string, string[]>()
for (const [blockId, block] of Object.entries(blocks)) {
const groupId = block.data?.groupId
if (!groupId) continue
if (!groups.has(groupId)) {
groups.set(groupId, [])
}
groups.get(groupId)!.push(blockId)
}
return groups
}
/**
* Expands changed block IDs to include all blocks in the same group.
* If any block in a group changed, all blocks in that group should be treated as changed.
*/
function expandChangedToGroups(
changedBlockIds: string[],
blockGroups: Map<string, string[]>,
blocks: Record<string, BlockState>
): Set<string> {
const expandedSet = new Set(changedBlockIds)
for (const blockId of changedBlockIds) {
const groupId = blocks[blockId]?.data?.groupId
if (groupId && blockGroups.has(groupId)) {
for (const groupBlockId of blockGroups.get(groupId)!) {
expandedSet.add(groupBlockId)
}
}
}
return expandedSet
}
/**
* Applies targeted layout to only reposition changed blocks.
* Unchanged blocks act as anchors to preserve existing layout.
* Blocks in groups are moved together as a unit.
*/
export function applyTargetedLayout(
blocks: Record<string, BlockState>,
@@ -45,9 +89,14 @@ export function applyTargetedLayout(
return blocks
}
const changedSet = new Set(changedBlockIds)
const blocksCopy: Record<string, BlockState> = JSON.parse(JSON.stringify(blocks))
// Identify block groups
const blockGroups = identifyBlockGroups(blocksCopy)
// Expand changed set to include all blocks in affected groups
const changedSet = expandChangedToGroups(changedBlockIds, blockGroups, blocksCopy)
// Pre-calculate container dimensions by laying out their children (bottom-up)
// This ensures accurate widths/heights before root-level layout
prepareContainerDimensions(
@@ -71,7 +120,8 @@ export function applyTargetedLayout(
changedSet,
verticalSpacing,
horizontalSpacing,
subflowDepths
subflowDepths,
blockGroups
)
for (const [parentId, childIds] of groups.children.entries()) {
@@ -83,7 +133,8 @@ export function applyTargetedLayout(
changedSet,
verticalSpacing,
horizontalSpacing,
subflowDepths
subflowDepths,
blockGroups
)
}
@@ -92,6 +143,7 @@ export function applyTargetedLayout(
/**
* Layouts a group of blocks (either root level or within a container)
* Blocks in block groups are moved together as a unit.
*/
function layoutGroup(
parentId: string | null,
@@ -101,7 +153,8 @@ function layoutGroup(
changedSet: Set<string>,
verticalSpacing: number,
horizontalSpacing: number,
subflowDepths: Map<string, number>
subflowDepths: Map<string, number>,
blockGroups: Map<string, string[]>
): void {
if (childIds.length === 0) return
@@ -141,7 +194,7 @@ function layoutGroup(
return
}
// Store old positions for anchor calculation
// Store old positions for anchor calculation and group delta tracking
const oldPositions = new Map<string, { x: number; y: number }>()
for (const id of layoutEligibleChildIds) {
const block = blocks[id]
@@ -185,14 +238,47 @@ function layoutGroup(
}
}
// Track which groups have already had their deltas applied
const processedGroups = new Set<string>()
// Apply new positions only to blocks that need layout
for (const id of needsLayout) {
const block = blocks[id]
const newPos = layoutPositions.get(id)
if (!block || !newPos) continue
block.position = {
x: newPos.x + offsetX,
y: newPos.y + offsetY,
const groupId = block.data?.groupId
// If this block is in a group, move all blocks in the group together
if (groupId && blockGroups.has(groupId) && !processedGroups.has(groupId)) {
processedGroups.add(groupId)
// Calculate the delta for this block (the one that needs layout)
const oldPos = oldPositions.get(id)
if (oldPos) {
const deltaX = newPos.x + offsetX - oldPos.x
const deltaY = newPos.y + offsetY - oldPos.y
// Apply delta to ALL blocks in the group using their original positions
for (const groupBlockId of blockGroups.get(groupId)!) {
const groupBlock = blocks[groupBlockId]
if (groupBlock && layoutEligibleChildIds.includes(groupBlockId)) {
const groupOriginalPos = oldPositions.get(groupBlockId)
if (groupOriginalPos) {
groupBlock.position = {
x: groupOriginalPos.x + deltaX,
y: groupOriginalPos.y + deltaY,
}
}
}
}
}
} else if (!groupId) {
// Non-grouped block - apply position normally
block.position = {
x: newPos.x + offsetX,
y: newPos.y + offsetY,
}
}
}
}

View File

@@ -41,11 +41,18 @@ export function isContainerType(blockType: string): boolean {
}
/**
* Checks if a block should be excluded from autolayout
* Checks if a block should be excluded from autolayout.
* Note blocks are excluded unless they are part of a group.
*/
export function shouldSkipAutoLayout(block?: BlockState): boolean {
export function shouldSkipAutoLayout(block?: BlockState, isInGroup?: boolean): boolean {
if (!block) return true
return AUTO_LAYOUT_EXCLUDED_TYPES.has(block.type)
// If the block type is normally excluded (e.g., note), but it's in a group, include it
if (AUTO_LAYOUT_EXCLUDED_TYPES.has(block.type)) {
// Check if block is in a group - if so, include it in layout
const blockIsInGroup = isInGroup ?? !!block.data?.groupId
return !blockIsInGroup
}
return false
}
/**

View File

@@ -2290,7 +2290,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
webhookId: { value: null },
},
}),
@@ -2302,7 +2302,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
webhookId: { value: 'wh_123456' },
},
}),
@@ -2318,7 +2318,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
triggerPath: { value: '' },
},
}),
@@ -2330,7 +2330,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
triggerPath: { value: '/api/webhooks/abc123' },
},
}),
@@ -2346,7 +2346,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
webhookId: { value: null },
triggerPath: { value: '' },
},
@@ -2359,7 +2359,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
webhookId: { value: 'wh_123456' },
triggerPath: { value: '/api/webhooks/abc123' },
},
@@ -2371,18 +2371,14 @@ describe('hasWorkflowChanged', () => {
})
it.concurrent(
'should detect change when actual config differs but runtime metadata also differs',
'should detect change when triggerConfig differs but runtime metadata also differs',
() => {
// Test that when a real config field changes along with runtime metadata,
// the change is still detected. Using 'model' as the config field since
// triggerConfig is now excluded from comparison (individual trigger fields
// are compared separately).
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
webhookId: { value: null },
},
}),
@@ -2394,7 +2390,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4o' },
triggerConfig: { value: { event: 'pull_request' } },
webhookId: { value: 'wh_123456' },
},
}),
@@ -2406,12 +2402,8 @@ describe('hasWorkflowChanged', () => {
)
it.concurrent(
'should not detect change when triggerConfig differs (individual fields compared separately)',
'should not detect change when runtime metadata is added to current state',
() => {
// triggerConfig is excluded from comparison because:
// 1. Individual trigger fields are stored as separate subblocks and compared individually
// 2. The client populates triggerConfig with default values from trigger definitions,
// which aren't present in the deployed state, causing false positive change detection
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
@@ -2428,36 +2420,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'pull_request', extraField: true } },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
}
)
it.concurrent(
'should not detect change when runtime metadata is added to current state',
() => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
webhookId: { value: 'wh_123456' },
triggerPath: { value: '/api/webhooks/abc123' },
},
@@ -2477,7 +2440,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
webhookId: { value: 'wh_old123' },
triggerPath: { value: '/api/webhooks/old' },
},
@@ -2490,7 +2453,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
},
}),
},

View File

@@ -1174,5 +1174,6 @@ export function stripWorkflowDiffMarkers(state: WorkflowState): WorkflowState {
edges: structuredClone(state.edges || []),
loops: structuredClone(state.loops || {}),
parallels: structuredClone(state.parallels || {}),
groups: structuredClone(state.groups || {}),
}
}

View File

@@ -1,80 +0,0 @@
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
export const DEFAULT_SUBBLOCK_TYPE = 'short-input'
/**
* Merges subblock values into the provided subblock structures.
* Falls back to a default subblock shape when a value has no structure.
* @param subBlocks - Existing subblock definitions from the workflow
* @param values - Stored subblock values keyed by subblock id
* @returns Merged subblock structures with updated values
*/
export function mergeSubBlockValues(
subBlocks: Record<string, unknown> | undefined,
values: Record<string, unknown> | undefined
): Record<string, unknown> {
const merged = { ...(subBlocks || {}) } as Record<string, any>
if (!values) return merged
Object.entries(values).forEach(([subBlockId, value]) => {
if (merged[subBlockId] && typeof merged[subBlockId] === 'object') {
merged[subBlockId] = {
...(merged[subBlockId] as Record<string, unknown>),
value,
}
return
}
merged[subBlockId] = {
id: subBlockId,
type: DEFAULT_SUBBLOCK_TYPE,
value,
}
})
return merged
}
/**
* Merges workflow block states with explicit subblock values while maintaining block structure.
* Values that are null or undefined do not override existing subblock values.
* @param blocks - Block configurations from workflow state
* @param subBlockValues - Subblock values keyed by blockId -> subBlockId -> value
* @param blockId - Optional specific block ID to merge (merges all if not provided)
* @returns Merged block states with updated subblocks
*/
export function mergeSubblockStateWithValues(
blocks: Record<string, BlockState>,
subBlockValues: Record<string, Record<string, unknown>> = {},
blockId?: string
): Record<string, BlockState> {
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
return Object.entries(blocksToProcess).reduce(
(acc, [id, block]) => {
if (!block) {
return acc
}
const blockSubBlocks = block.subBlocks || {}
const blockValues = subBlockValues[id] || {}
const filteredValues = Object.fromEntries(
Object.entries(blockValues).filter(([, value]) => value !== null && value !== undefined)
)
const mergedSubBlocks = mergeSubBlockValues(blockSubBlocks, filteredValues) as Record<
string,
SubBlockState
>
acc[id] = {
...block,
subBlocks: mergedSubBlocks,
}
return acc
},
{} as Record<string, BlockState>
)
}

View File

@@ -104,7 +104,6 @@
"groq-sdk": "^0.15.0",
"html-to-image": "1.11.13",
"html-to-text": "^9.0.5",
"idb-keyval": "6.2.2",
"imapflow": "1.2.4",
"input-otp": "^1.4.2",
"ioredis": "^5.6.0",

View File

@@ -16,6 +16,8 @@ export const BLOCKS_OPERATIONS = {
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
BATCH_UPDATE_PARENT: 'batch-update-parent',
GROUP_BLOCKS: 'group-blocks',
UNGROUP_BLOCKS: 'ungroup-blocks',
} as const
export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS]
@@ -87,6 +89,8 @@ export const UNDO_REDO_OPERATIONS = {
APPLY_DIFF: 'apply-diff',
ACCEPT_DIFF: 'accept-diff',
REJECT_DIFF: 'reject-diff',
GROUP_BLOCKS: 'group-blocks',
UNGROUP_BLOCKS: 'ungroup-blocks',
} as const
export type UndoRedoOperation = (typeof UNDO_REDO_OPERATIONS)[keyof typeof UNDO_REDO_OPERATIONS]

View File

@@ -7,7 +7,6 @@ import postgres from 'postgres'
import { env } from '@/lib/core/config/env'
import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { mergeSubBlockValues } from '@/lib/workflows/subblocks'
import {
BLOCK_OPERATIONS,
BLOCKS_OPERATIONS,
@@ -456,7 +455,7 @@ async function handleBlocksOperationTx(
}
case BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS: {
const { blocks, edges, loops, parallels, subBlockValues } = payload
const { blocks, edges, loops, parallels } = payload
logger.info(`Batch adding blocks to workflow ${workflowId}`, {
blockCount: blocks?.length || 0,
@@ -466,30 +465,22 @@ async function handleBlocksOperationTx(
})
if (blocks && blocks.length > 0) {
const blockValues = blocks.map((block: Record<string, unknown>) => {
const blockId = block.id as string
const mergedSubBlocks = mergeSubBlockValues(
block.subBlocks as Record<string, unknown>,
subBlockValues?.[blockId]
)
return {
id: blockId,
workflowId,
type: block.type as string,
name: block.name as string,
positionX: (block.position as { x: number; y: number }).x,
positionY: (block.position as { x: number; y: number }).y,
data: (block.data as Record<string, unknown>) || {},
subBlocks: mergedSubBlocks,
outputs: (block.outputs as Record<string, unknown>) || {},
enabled: (block.enabled as boolean) ?? true,
horizontalHandles: (block.horizontalHandles as boolean) ?? true,
advancedMode: (block.advancedMode as boolean) ?? false,
triggerMode: (block.triggerMode as boolean) ?? false,
height: (block.height as number) || 0,
}
})
const blockValues = blocks.map((block: Record<string, unknown>) => ({
id: block.id as string,
workflowId,
type: block.type as string,
name: block.name as string,
positionX: (block.position as { x: number; y: number }).x,
positionY: (block.position as { x: number; y: number }).y,
data: (block.data as Record<string, unknown>) || {},
subBlocks: (block.subBlocks as Record<string, unknown>) || {},
outputs: (block.outputs as Record<string, unknown>) || {},
enabled: (block.enabled as boolean) ?? true,
horizontalHandles: (block.horizontalHandles as boolean) ?? true,
advancedMode: (block.advancedMode as boolean) ?? false,
triggerMode: (block.triggerMode as boolean) ?? false,
height: (block.height as number) || 0,
}))
await tx.insert(workflowBlocks).values(blockValues)
@@ -819,6 +810,104 @@ async function handleBlocksOperationTx(
break
}
case BLOCKS_OPERATIONS.GROUP_BLOCKS: {
const { blockIds, groupId } = payload
if (!Array.isArray(blockIds) || blockIds.length === 0 || !groupId) {
logger.debug('Invalid payload for group blocks operation')
return
}
logger.info(`Grouping ${blockIds.length} blocks into group ${groupId} in workflow ${workflowId}`)
// Update blocks: set groupId and push to groupStack
for (const blockId of blockIds) {
const [currentBlock] = await tx
.select({ data: workflowBlocks.data })
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
if (!currentBlock) {
logger.warn(`Block ${blockId} not found for grouping`)
continue
}
const currentData = (currentBlock?.data || {}) as Record<string, any>
const currentStack = Array.isArray(currentData.groupStack) ? currentData.groupStack : []
const updatedData = {
...currentData,
groupId,
groupStack: [...currentStack, groupId],
}
await tx
.update(workflowBlocks)
.set({
data: updatedData,
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
}
logger.debug(`Grouped ${blockIds.length} blocks into group ${groupId}`)
break
}
case BLOCKS_OPERATIONS.UNGROUP_BLOCKS: {
const { groupId, blockIds } = payload
if (!groupId || !Array.isArray(blockIds)) {
logger.debug('Invalid payload for ungroup blocks operation')
return
}
logger.info(`Ungrouping ${blockIds.length} blocks from group ${groupId} in workflow ${workflowId}`)
// Update blocks: pop from groupStack and set groupId to the previous level
for (const blockId of blockIds) {
const [currentBlock] = await tx
.select({ data: workflowBlocks.data })
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
if (!currentBlock) {
logger.warn(`Block ${blockId} not found for ungrouping`)
continue
}
const currentData = (currentBlock?.data || {}) as Record<string, any>
const currentStack = Array.isArray(currentData.groupStack) ? [...currentData.groupStack] : []
// Pop the current groupId from the stack
if (currentStack.length > 0 && currentStack[currentStack.length - 1] === groupId) {
currentStack.pop()
}
// The new groupId is the top of the remaining stack, or undefined if empty
const newGroupId = currentStack.length > 0 ? currentStack[currentStack.length - 1] : undefined
let updatedData: Record<string, any>
if (newGroupId) {
updatedData = { ...currentData, groupId: newGroupId, groupStack: currentStack }
} else {
// Remove groupId and groupStack if stack is empty
const { groupId: _removed, groupStack: _removedStack, ...restData } = currentData
updatedData = restData
}
await tx
.update(workflowBlocks)
.set({
data: updatedData,
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
}
logger.debug(`Ungrouped ${blockIds.length} blocks from group ${groupId}`)
break
}
default:
throw new Error(`Unsupported blocks operation: ${operation}`)
}

View File

@@ -465,6 +465,70 @@ export function setupOperationsHandlers(
return
}
if (
target === OPERATION_TARGETS.BLOCKS &&
operation === BLOCKS_OPERATIONS.GROUP_BLOCKS
) {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
room.lastModified = Date.now()
socket.to(workflowId).emit('workflow-operation', {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: { workflowId, operationId: crypto.randomUUID() },
})
if (operationId) {
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
}
return
}
if (
target === OPERATION_TARGETS.BLOCKS &&
operation === BLOCKS_OPERATIONS.UNGROUP_BLOCKS
) {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
room.lastModified = Date.now()
socket.to(workflowId).emit('workflow-operation', {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: { workflowId, operationId: crypto.randomUUID() },
})
if (operationId) {
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
}
return
}
if (target === OPERATION_TARGETS.EDGES && operation === EDGES_OPERATIONS.BATCH_ADD_EDGES) {
await persistWorkflowOperation(workflowId, {
operation,

View File

@@ -30,6 +30,8 @@ const WRITE_OPERATIONS: string[] = [
BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES,
BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT,
BLOCKS_OPERATIONS.GROUP_BLOCKS,
BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
// Edge operations
EDGE_OPERATIONS.ADD,
EDGE_OPERATIONS.REMOVE,

View File

@@ -221,6 +221,30 @@ export const BatchUpdateParentSchema = z.object({
operationId: z.string().optional(),
})
export const GroupBlocksSchema = z.object({
operation: z.literal(BLOCKS_OPERATIONS.GROUP_BLOCKS),
target: z.literal(OPERATION_TARGETS.BLOCKS),
payload: z.object({
blockIds: z.array(z.string()),
groupId: z.string(),
name: z.string().optional(),
}),
timestamp: z.number(),
operationId: z.string().optional(),
})
export const UngroupBlocksSchema = z.object({
operation: z.literal(BLOCKS_OPERATIONS.UNGROUP_BLOCKS),
target: z.literal(OPERATION_TARGETS.BLOCKS),
payload: z.object({
groupId: z.string(),
blockIds: z.array(z.string()),
parentGroupId: z.string().optional(),
}),
timestamp: z.number(),
operationId: z.string().optional(),
})
export const WorkflowOperationSchema = z.union([
BlockOperationSchema,
BatchPositionUpdateSchema,
@@ -229,6 +253,8 @@ export const WorkflowOperationSchema = z.union([
BatchToggleEnabledSchema,
BatchToggleHandlesSchema,
BatchUpdateParentSchema,
GroupBlocksSchema,
UngroupBlocksSchema,
EdgeOperationSchema,
BatchAddEdgesSchema,
BatchRemoveEdgesSchema,

View File

@@ -25,6 +25,7 @@ function captureWorkflowSnapshot(): WorkflowState {
edges: rawState.edges || [],
loops: rawState.loops || {},
parallels: rawState.parallels || {},
groups: rawState.groups || {},
lastSaved: Date.now(),
}
}

View File

@@ -1,3 +1,2 @@
export { indexedDBStorage } from './storage'
export { useTerminalConsoleStore } from './store'
export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './types'

View File

@@ -1,81 +0,0 @@
import { createLogger } from '@sim/logger'
import { del, get, set } from 'idb-keyval'
import type { StateStorage } from 'zustand/middleware'
const logger = createLogger('ConsoleStorage')
const STORE_KEY = 'terminal-console-store'
const MIGRATION_KEY = 'terminal-console-store-migrated'
/**
* Promise that resolves when migration is complete.
* Used to ensure getItem waits for migration before reading.
*/
let migrationPromise: Promise<void> | null = null
/**
* Migrates existing console data from localStorage to IndexedDB.
* Runs once on first load, then marks migration as complete.
*/
async function migrateFromLocalStorage(): Promise<void> {
if (typeof window === 'undefined') return
try {
const migrated = await get<boolean>(MIGRATION_KEY)
if (migrated) return
const localData = localStorage.getItem(STORE_KEY)
if (localData) {
await set(STORE_KEY, localData)
localStorage.removeItem(STORE_KEY)
logger.info('Migrated console store to IndexedDB')
}
await set(MIGRATION_KEY, true)
} catch (error) {
logger.warn('Migration from localStorage failed', { error })
}
}
if (typeof window !== 'undefined') {
migrationPromise = migrateFromLocalStorage().finally(() => {
migrationPromise = null
})
}
export const indexedDBStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
if (typeof window === 'undefined') return null
// Ensure migration completes before reading
if (migrationPromise) {
await migrationPromise
}
try {
const value = await get<string>(name)
return value ?? null
} catch (error) {
logger.warn('IndexedDB read failed', { name, error })
return null
}
},
setItem: async (name: string, value: string): Promise<void> => {
if (typeof window === 'undefined') return
try {
await set(name, value)
} catch (error) {
logger.warn('IndexedDB write failed', { name, error })
}
},
removeItem: async (name: string): Promise<void> => {
if (typeof window === 'undefined') return
try {
await del(name)
} catch (error) {
logger.warn('IndexedDB delete failed', { name, error })
}
},
}

View File

@@ -1,22 +1,18 @@
import { createLogger } from '@sim/logger'
import { create } from 'zustand'
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import { devtools, persist } from 'zustand/middleware'
import { redactApiKeys } from '@/lib/core/security/redaction'
import type { NormalizedBlockOutput } from '@/executor/types'
import { useExecutionStore } from '@/stores/execution'
import { useNotificationStore } from '@/stores/notifications'
import { useGeneralStore } from '@/stores/settings/general'
import { indexedDBStorage } from '@/stores/terminal/console/storage'
import type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from '@/stores/terminal/console/types'
const logger = createLogger('TerminalConsoleStore')
/**
* Maximum number of console entries to keep per workflow.
* Keeps the stored data size reasonable and improves performance.
* Updates a NormalizedBlockOutput with new content
*/
const MAX_ENTRIES_PER_WORKFLOW = 1000
const updateBlockOutput = (
existingOutput: NormalizedBlockOutput | undefined,
contentUpdate: string
@@ -27,6 +23,9 @@ const updateBlockOutput = (
}
}
/**
* Checks if output represents a streaming object that should be skipped
*/
const isStreamingOutput = (output: any): boolean => {
if (typeof ReadableStream !== 'undefined' && output instanceof ReadableStream) {
return true
@@ -45,6 +44,9 @@ const isStreamingOutput = (output: any): boolean => {
)
}
/**
* Checks if entry should be skipped to prevent duplicates
*/
const shouldSkipEntry = (output: any): boolean => {
if (typeof output !== 'object' || !output) {
return false
@@ -67,9 +69,6 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
(set, get) => ({
entries: [],
isOpen: false,
_hasHydrated: false,
setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }),
addConsole: (entry: Omit<ConsoleEntry, 'id' | 'timestamp'>) => {
set((state) => {
@@ -95,59 +94,7 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
timestamp: new Date().toISOString(),
}
const newEntries = [newEntry, ...state.entries]
const executionsToRemove = new Set<string>()
const workflowGroups = new Map<string, ConsoleEntry[]>()
for (const e of newEntries) {
const group = workflowGroups.get(e.workflowId) || []
group.push(e)
workflowGroups.set(e.workflowId, group)
}
for (const [workflowId, entries] of workflowGroups) {
if (entries.length <= MAX_ENTRIES_PER_WORKFLOW) continue
const execOrder: string[] = []
const seen = new Set<string>()
for (const e of entries) {
const execId = e.executionId ?? e.id
if (!seen.has(execId)) {
execOrder.push(execId)
seen.add(execId)
}
}
const counts = new Map<string, number>()
for (const e of entries) {
const execId = e.executionId ?? e.id
counts.set(execId, (counts.get(execId) || 0) + 1)
}
let total = 0
const toKeep = new Set<string>()
for (const execId of execOrder) {
const c = counts.get(execId) || 0
if (total + c <= MAX_ENTRIES_PER_WORKFLOW) {
toKeep.add(execId)
total += c
}
}
for (const execId of execOrder) {
if (!toKeep.has(execId)) {
executionsToRemove.add(`${workflowId}:${execId}`)
}
}
}
const trimmedEntries = newEntries.filter((e) => {
const key = `${e.workflowId}:${e.executionId ?? e.id}`
return !executionsToRemove.has(key)
})
return { entries: trimmedEntries }
return { entries: [newEntry, ...state.entries] }
})
const newEntry = get().entries[0]
@@ -183,6 +130,10 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
return newEntry
},
/**
* Clears console entries for a specific workflow and clears the run path
* @param workflowId - The workflow ID to clear entries for
*/
clearWorkflowConsole: (workflowId: string) => {
set((state) => ({
entries: state.entries.filter((entry) => entry.workflowId !== workflowId),
@@ -197,6 +148,9 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
return
}
/**
* Formats a value for CSV export
*/
const formatCSVValue = (value: any): string => {
if (value === null || value === undefined) {
return ''
@@ -343,35 +297,7 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
}),
{
name: 'terminal-console-store',
storage: createJSONStorage(() => indexedDBStorage),
partialize: (state) => ({
entries: state.entries,
isOpen: state.isOpen,
}),
onRehydrateStorage: () => (_state, error) => {
if (error) {
logger.error('Failed to rehydrate console store', { error })
}
},
merge: (persistedState, currentState) => {
const persisted = persistedState as Partial<ConsoleStore> | undefined
return {
...currentState,
entries: persisted?.entries ?? currentState.entries,
isOpen: persisted?.isOpen ?? currentState.isOpen,
}
},
}
)
)
)
if (typeof window !== 'undefined') {
useTerminalConsoleStore.persist.onFinishHydration(() => {
useTerminalConsoleStore.setState({ _hasHydrated: true })
})
if (useTerminalConsoleStore.persist.hasHydrated()) {
useTerminalConsoleStore.setState({ _hasHydrated: true })
}
}

View File

@@ -1,6 +1,9 @@
import type { NormalizedBlockOutput } from '@/executor/types'
import type { SubflowType } from '@/stores/workflows/workflow/types'
/**
* Console entry for terminal logs
*/
export interface ConsoleEntry {
id: string
timestamp: string
@@ -22,6 +25,9 @@ export interface ConsoleEntry {
iterationType?: SubflowType
}
/**
* Console update payload for partial updates
*/
export interface ConsoleUpdate {
content?: string
output?: Partial<NormalizedBlockOutput>
@@ -34,6 +40,9 @@ export interface ConsoleUpdate {
input?: any
}
/**
* Console store state and actions
*/
export interface ConsoleStore {
entries: ConsoleEntry[]
isOpen: boolean
@@ -43,6 +52,4 @@ export interface ConsoleStore {
getWorkflowEntries: (workflowId: string) => ConsoleEntry[]
toggleConsole: () => void
updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => void
_hasHydrated: boolean
setHasHydrated: (hasHydrated: boolean) => void
}

View File

@@ -126,6 +126,23 @@ export interface RejectDiffOperation extends BaseOperation {
}
}
export interface GroupBlocksOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.GROUP_BLOCKS
data: {
groupId: string
blockIds: string[]
}
}
export interface UngroupBlocksOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS
data: {
groupId: string
blockIds: string[]
parentGroupId?: string
}
}
export type Operation =
| BatchAddBlocksOperation
| BatchRemoveBlocksOperation
@@ -139,6 +156,8 @@ export type Operation =
| ApplyDiffOperation
| AcceptDiffOperation
| RejectDiffOperation
| GroupBlocksOperation
| UngroupBlocksOperation
export interface OperationEntry {
id: string

View File

@@ -6,8 +6,10 @@ import type {
BatchRemoveBlocksOperation,
BatchRemoveEdgesOperation,
BatchUpdateParentOperation,
GroupBlocksOperation,
Operation,
OperationEntry,
UngroupBlocksOperation,
} from '@/stores/undo-redo/types'
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
@@ -164,6 +166,30 @@ export function createInverseOperation(operation: Operation): Operation {
},
}
case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: {
const op = operation as GroupBlocksOperation
return {
...operation,
type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS,
data: {
groupId: op.data.groupId,
blockIds: op.data.blockIds,
},
} as UngroupBlocksOperation
}
case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: {
const op = operation as UngroupBlocksOperation
return {
...operation,
type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS,
data: {
groupId: op.data.groupId,
blockIds: op.data.blockIds,
},
} as GroupBlocksOperation
}
default: {
const exhaustiveCheck: never = operation
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)

View File

@@ -16,6 +16,7 @@ export function cloneWorkflowState(state: WorkflowState): WorkflowState {
edges: structuredClone(state.edges || []),
loops: structuredClone(state.loops || {}),
parallels: structuredClone(state.parallels || {}),
groups: structuredClone(state.groups || {}),
}
}

View File

@@ -298,11 +298,26 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
let workflowState: any
if (workflowData?.state) {
const blocks = workflowData.state.blocks || {}
// Reconstruct groups from blocks' groupId data
const reconstructedGroups: Record<string, { id: string; blockIds: string[] }> = {}
Object.entries(blocks).forEach(([blockId, block]: [string, any]) => {
const groupId = block?.data?.groupId
if (groupId) {
if (!reconstructedGroups[groupId]) {
reconstructedGroups[groupId] = { id: groupId, blockIds: [] }
}
reconstructedGroups[groupId].blockIds.push(blockId)
}
})
workflowState = {
blocks: workflowData.state.blocks || {},
blocks,
edges: workflowData.state.edges || [],
loops: workflowData.state.loops || {},
parallels: workflowData.state.parallels || {},
groups: reconstructedGroups,
lastSaved: Date.now(),
deploymentStatuses: {},
}
@@ -312,6 +327,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
edges: [],
loops: {},
parallels: {},
groups: {},
deploymentStatuses: {},
lastSaved: Date.now(),
}

View File

@@ -8,8 +8,7 @@
* or React hooks, making it safe for use in Next.js API routes.
*/
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import type { BlockState } from '@/stores/workflows/workflow/types'
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
/**
* Server-safe version of mergeSubblockState for API routes
@@ -27,7 +26,72 @@ export function mergeSubblockState(
subBlockValues: Record<string, Record<string, any>> = {},
blockId?: string
): Record<string, BlockState> {
return mergeSubblockStateWithValues(blocks, subBlockValues, blockId)
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
return Object.entries(blocksToProcess).reduce(
(acc, [id, block]) => {
// Skip if block is undefined
if (!block) {
return acc
}
// Initialize subBlocks if not present
const blockSubBlocks = block.subBlocks || {}
// Get stored values for this block
const blockValues = subBlockValues[id] || {}
// Create a deep copy of the block's subBlocks to maintain structure
const mergedSubBlocks = Object.entries(blockSubBlocks).reduce(
(subAcc, [subBlockId, subBlock]) => {
// Skip if subBlock is undefined
if (!subBlock) {
return subAcc
}
// Get the stored value for this subblock
const storedValue = blockValues[subBlockId]
// Create a new subblock object with the same structure but updated value
subAcc[subBlockId] = {
...subBlock,
value: storedValue !== undefined && storedValue !== null ? storedValue : subBlock.value,
}
return subAcc
},
{} as Record<string, SubBlockState>
)
// Return the full block state with updated subBlocks
acc[id] = {
...block,
subBlocks: mergedSubBlocks,
}
// Add any values that exist in the provided values but aren't in the block structure
// This handles cases where block config has been updated but values still exist
Object.entries(blockValues).forEach(([subBlockId, value]) => {
if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) {
// Create a minimal subblock structure
mergedSubBlocks[subBlockId] = {
id: subBlockId,
type: 'short-input', // Default type that's safe to use
value: value,
}
}
})
// Update the block with the final merged subBlocks (including orphaned values)
acc[id] = {
...block,
subBlocks: mergedSubBlocks,
}
return acc
},
{} as Record<string, BlockState>
)
}
/**

View File

@@ -1,7 +1,6 @@
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { mergeSubBlockValues, mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { getBlock } from '@/blocks'
import { normalizeName } from '@/executor/constants'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -19,19 +18,6 @@ const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath']
export { normalizeName }
export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
return edgesToAdd.filter((edge) => {
if (edge.source === edge.target) return false
return !currentEdges.some(
(e) =>
e.source === edge.source &&
e.sourceHandle === edge.sourceHandle &&
e.target === edge.target &&
e.targetHandle === edge.targetHandle
)
})
}
export interface RegeneratedState {
blocks: Record<string, BlockState>
edges: Edge[]
@@ -201,20 +187,27 @@ export function prepareDuplicateBlockState(options: PrepareDuplicateBlockStateOp
Object.entries(subBlockValues).filter(([key]) => !WEBHOOK_SUBBLOCK_FIELDS.includes(key))
)
const baseSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
const mergedSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
? JSON.parse(JSON.stringify(sourceBlock.subBlocks))
: {}
WEBHOOK_SUBBLOCK_FIELDS.forEach((field) => {
if (field in baseSubBlocks) {
delete baseSubBlocks[field]
if (field in mergedSubBlocks) {
delete mergedSubBlocks[field]
}
})
const mergedSubBlocks = mergeSubBlockValues(baseSubBlocks, filteredSubBlockValues) as Record<
string,
SubBlockState
>
Object.entries(filteredSubBlockValues).forEach(([subblockId, value]) => {
if (mergedSubBlocks[subblockId]) {
mergedSubBlocks[subblockId].value = value as SubBlockState['value']
} else {
mergedSubBlocks[subblockId] = {
id: subblockId,
type: 'short-input',
value: value as SubBlockState['value'],
}
}
})
const block: BlockState = {
id: newId,
@@ -249,16 +242,11 @@ export function mergeSubblockState(
workflowId?: string,
blockId?: string
): Record<string, BlockState> {
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
const subBlockStore = useSubBlockStore.getState()
const workflowSubblockValues = workflowId ? subBlockStore.workflowValues[workflowId] || {} : {}
if (workflowId) {
return mergeSubblockStateWithValues(blocks, workflowSubblockValues, blockId)
}
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
return Object.entries(blocksToProcess).reduce(
(acc, [id, block]) => {
if (!block) {
@@ -337,14 +325,8 @@ export async function mergeSubblockStateAsync(
workflowId?: string,
blockId?: string
): Promise<Record<string, BlockState>> {
const subBlockStore = useSubBlockStore.getState()
if (workflowId) {
const workflowValues = subBlockStore.workflowValues[workflowId] || {}
return mergeSubblockStateWithValues(blocks, workflowValues, blockId)
}
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
const subBlockStore = useSubBlockStore.getState()
// Process blocks in parallel for better performance
const processedBlockEntries = await Promise.all(
@@ -362,7 +344,16 @@ export async function mergeSubblockStateAsync(
return null
}
const storedValue = subBlockStore.getValue(id, subBlockId)
let storedValue = null
if (workflowId) {
const workflowValues = subBlockStore.workflowValues[workflowId]
if (workflowValues?.[id]) {
storedValue = workflowValues[id][subBlockId]
}
} else {
storedValue = subBlockStore.getValue(id, subBlockId)
}
return [
subBlockId,
@@ -381,6 +372,23 @@ export async function mergeSubblockStateAsync(
subBlockEntries.filter((entry): entry is readonly [string, SubBlockState] => entry !== null)
) as Record<string, SubBlockState>
// Add any values that exist in the store but aren't in the block structure
// This handles cases where block config has been updated but values still exist
// IMPORTANT: This includes runtime subblock IDs like webhookId, triggerPath, etc.
if (workflowId) {
const workflowValues = subBlockStore.workflowValues[workflowId]
const blockValues = workflowValues?.[id] || {}
Object.entries(blockValues).forEach(([subBlockId, value]) => {
if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) {
mergedSubBlocks[subBlockId] = {
id: subBlockId,
type: 'short-input',
value: value as SubBlockState['value'],
}
}
})
}
// Return the full block state with updated subBlocks (including orphaned values)
return [
id,

View File

@@ -297,7 +297,7 @@ describe('workflow store', () => {
expectEdgeConnects(edges, 'block-1', 'block-2')
})
it('should not add duplicate connections', () => {
it('should not add duplicate edges', () => {
const { addBlock, batchAddEdges } = useWorkflowStore.getState()
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
@@ -309,6 +309,17 @@ describe('workflow store', () => {
const state = useWorkflowStore.getState()
expectEdgeCount(state, 1)
})
it('should prevent self-referencing edges', () => {
const { addBlock, batchAddEdges } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Self', { x: 0, y: 0 })
batchAddEdges([{ id: 'e1', source: 'block-1', target: 'block-1' }])
const state = useWorkflowStore.getState()
expectEdgeCount(state, 0)
})
})
describe('batchRemoveEdges', () => {

View File

@@ -9,12 +9,7 @@ import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import {
filterNewEdges,
getUniqueBlockName,
mergeSubblockState,
normalizeName,
} from '@/stores/workflows/utils'
import { getUniqueBlockName, mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
import type {
Position,
SubBlockState,
@@ -100,6 +95,7 @@ const initialState = {
edges: [],
loops: {},
parallels: {},
groups: {},
lastSaved: undefined,
deploymentStatuses: {},
needsRedeployment: false,
@@ -501,11 +497,25 @@ export const useWorkflowStore = create<WorkflowStore>()(
batchAddEdges: (edges: Edge[]) => {
const currentEdges = get().edges
const filtered = filterNewEdges(edges, currentEdges)
const newEdges = [...currentEdges]
const existingEdgeIds = new Set(currentEdges.map((e) => e.id))
// Track existing connections to prevent duplicates (same source->target)
const existingConnections = new Set(currentEdges.map((e) => `${e.source}->${e.target}`))
for (const edge of filtered) {
for (const edge of edges) {
// Skip if edge ID already exists
if (existingEdgeIds.has(edge.id)) continue
// Skip self-referencing edges
if (edge.source === edge.target) continue
// Skip if connection already exists (same source and target)
const connectionKey = `${edge.source}->${edge.target}`
if (existingConnections.has(connectionKey)) continue
// Skip if would create a cycle
if (wouldCreateCycle([...newEdges], edge.source, edge.target)) continue
newEdges.push({
id: edge.id || crypto.randomUUID(),
source: edge.source,
@@ -515,6 +525,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
type: edge.type || 'default',
data: edge.data || {},
})
existingEdgeIds.add(edge.id)
existingConnections.add(connectionKey)
}
const blocks = get().blocks
@@ -566,6 +578,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
edges: state.edges,
loops: state.loops,
parallels: state.parallels,
groups: state.groups,
lastSaved: state.lastSaved,
deploymentStatuses: state.deploymentStatuses,
needsRedeployment: state.needsRedeployment,
@@ -586,6 +599,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
Object.keys(workflowState.parallels || {}).length > 0
? workflowState.parallels
: generateParallelBlocks(nextBlocks)
const nextGroups = workflowState.groups || state.groups
return {
...state,
@@ -593,6 +607,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
edges: nextEdges,
loops: nextLoops,
parallels: nextParallels,
groups: nextGroups,
deploymentStatuses: workflowState.deploymentStatuses || state.deploymentStatuses,
needsRedeployment:
workflowState.needsRedeployment !== undefined
@@ -639,8 +654,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
const newName = getUniqueBlockName(block.name, get().blocks)
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
const mergedBlock = mergeSubblockState(get().blocks, activeWorkflowId || undefined, id)[id]
const mergedBlock = mergeSubblockState(get().blocks, id)[id]
const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce(
(acc, [subId, subBlock]) => ({
@@ -669,6 +683,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
parallels: get().generateParallelBlocks(),
}
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) {
const subBlockValues =
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
@@ -1322,6 +1337,126 @@ export const useWorkflowStore = create<WorkflowStore>()(
getDragStartPosition: () => {
return get().dragStartPosition || null
},
groupBlocks: (blockIds: string[], groupId?: string) => {
if (blockIds.length === 0) return ''
const newGroupId = groupId || crypto.randomUUID()
const currentGroups = get().groups || {}
const currentBlocks = get().blocks
// Create the new group with all selected block IDs
const updatedGroups = { ...currentGroups }
updatedGroups[newGroupId] = {
id: newGroupId,
blockIds: [...blockIds],
}
// Update blocks: set groupId and push to groupStack
const newBlocks = { ...currentBlocks }
for (const blockId of blockIds) {
if (newBlocks[blockId]) {
const currentData = newBlocks[blockId].data || {}
const currentStack = Array.isArray(currentData.groupStack) ? currentData.groupStack : []
newBlocks[blockId] = {
...newBlocks[blockId],
data: {
...currentData,
groupId: newGroupId,
groupStack: [...currentStack, newGroupId],
},
}
}
}
set({
blocks: newBlocks,
groups: updatedGroups,
})
get().updateLastSaved()
logger.info('Created block group', {
groupId: newGroupId,
blockCount: blockIds.length,
})
return newGroupId
},
ungroupBlocks: (groupId: string) => {
const currentGroups = get().groups || {}
const currentBlocks = get().blocks
const group = currentGroups[groupId]
if (!group) {
logger.warn('Attempted to ungroup non-existent group', { groupId })
return []
}
const blockIds = [...group.blockIds]
// Remove the group from the groups record
const updatedGroups = { ...currentGroups }
delete updatedGroups[groupId]
// Update blocks: pop from groupStack and set groupId to the previous level
const newBlocks = { ...currentBlocks }
for (const blockId of blockIds) {
if (newBlocks[blockId]) {
const currentData = { ...newBlocks[blockId].data }
const currentStack = Array.isArray(currentData.groupStack)
? [...currentData.groupStack]
: []
// Pop the current groupId from the stack
if (currentStack.length > 0 && currentStack[currentStack.length - 1] === groupId) {
currentStack.pop()
}
// The new groupId is the top of the remaining stack, or undefined if empty
const newGroupId =
currentStack.length > 0 ? currentStack[currentStack.length - 1] : undefined
if (newGroupId) {
currentData.groupId = newGroupId
currentData.groupStack = currentStack
} else {
// Remove groupId and groupStack if stack is empty
delete currentData.groupId
delete currentData.groupStack
}
newBlocks[blockId] = {
...newBlocks[blockId],
data: currentData,
}
}
}
set({
blocks: newBlocks,
groups: updatedGroups,
})
get().updateLastSaved()
logger.info('Ungrouped blocks', {
groupId,
blockCount: blockIds.length,
})
return blockIds
},
getGroupBlockIds: (groupId: string) => {
const groups = get().groups || {}
const group = groups[groupId]
if (!group) return []
return [...group.blockIds]
},
getGroups: () => {
return get().groups || {}
},
}),
{ name: 'workflow-store' }
)

View File

@@ -63,6 +63,11 @@ export interface BlockData {
// Container node type (for ReactFlow node type determination)
type?: string
// Block group membership
groupId?: string
/** Stack of group IDs for hierarchical grouping (oldest to newest) */
groupStack?: string[]
}
export interface BlockLayoutState {
@@ -144,6 +149,20 @@ export interface Variable {
value: unknown
}
/**
* Represents a group of blocks on the canvas.
* Groups can be nested (a group can contain other groups via block membership).
* When a block is in a group, it stores the groupId in its data.
*/
export interface BlockGroup {
/** Unique identifier for the group */
id: string
/** Optional display name for the group */
name?: string
/** Block IDs that are direct members of this group */
blockIds: string[]
}
export interface DragStartPosition {
id: string
x: number
@@ -157,6 +176,8 @@ export interface WorkflowState {
lastSaved?: number
loops: Record<string, Loop>
parallels: Record<string, Parallel>
/** Block groups for organizing blocks on the canvas */
groups?: Record<string, BlockGroup>
lastUpdate?: number
metadata?: {
name?: string
@@ -243,6 +264,28 @@ export interface WorkflowActions {
workflowState: WorkflowState,
options?: { updateLastSaved?: boolean }
) => void
// Block group operations
/**
* Groups the specified blocks together.
* If any blocks are already in a group, they are removed from their current group first.
* @returns The new group ID
*/
groupBlocks: (blockIds: string[], groupId?: string) => string
/**
* Ungroups a group, removing it and releasing its blocks.
* If the group has a parent group, blocks are moved to the parent group.
* @returns The block IDs that were in the group
*/
ungroupBlocks: (groupId: string) => string[]
/**
* Gets all block IDs in a group, including blocks in nested groups (recursive).
*/
getGroupBlockIds: (groupId: string, recursive?: boolean) => string[]
/**
* Gets all groups in the workflow.
*/
getGroups: () => Record<string, BlockGroup>
}
export type WorkflowStore = WorkflowState & WorkflowActions

View File

@@ -30,14 +30,11 @@ export const a2aCancelTaskTool: ToolConfig<A2ACancelTaskParams, A2ACancelTaskRes
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: A2ACancelTaskParams) => {
const body: Record<string, string> = {
agentUrl: params.agentUrl,
taskId: params.taskId,
}
if (params.apiKey) body.apiKey = params.apiKey
return body
},
body: (params: A2ACancelTaskParams) => ({
agentUrl: params.agentUrl,
taskId: params.taskId,
apiKey: params.apiKey,
}),
},
transformResponse: async (response: Response) => {

View File

@@ -38,16 +38,12 @@ export const a2aDeletePushNotificationTool: ToolConfig<
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, string> = {
agentUrl: params.agentUrl,
taskId: params.taskId,
}
if (params.pushNotificationConfigId)
body.pushNotificationConfigId = params.pushNotificationConfigId
if (params.apiKey) body.apiKey = params.apiKey
return body
},
body: (params) => ({
agentUrl: params.agentUrl,
taskId: params.taskId,
pushNotificationConfigId: params.pushNotificationConfigId,
apiKey: params.apiKey,
}),
},
transformResponse: async (response: Response) => {

View File

@@ -25,13 +25,10 @@ export const a2aGetAgentCardTool: ToolConfig<A2AGetAgentCardParams, A2AGetAgentC
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, string> = {
agentUrl: params.agentUrl,
}
if (params.apiKey) body.apiKey = params.apiKey
return body
},
body: (params) => ({
agentUrl: params.agentUrl,
apiKey: params.apiKey,
}),
},
transformResponse: async (response: Response) => {

View File

@@ -33,14 +33,11 @@ export const a2aGetPushNotificationTool: ToolConfig<
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, string> = {
agentUrl: params.agentUrl,
taskId: params.taskId,
}
if (params.apiKey) body.apiKey = params.apiKey
return body
},
body: (params) => ({
agentUrl: params.agentUrl,
taskId: params.taskId,
apiKey: params.apiKey,
}),
},
transformResponse: async (response: Response) => {

View File

@@ -34,15 +34,12 @@ export const a2aGetTaskTool: ToolConfig<A2AGetTaskParams, A2AGetTaskResponse> =
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: A2AGetTaskParams) => {
const body: Record<string, string | number> = {
agentUrl: params.agentUrl,
taskId: params.taskId,
}
if (params.apiKey) body.apiKey = params.apiKey
if (params.historyLength) body.historyLength = params.historyLength
return body
},
body: (params: A2AGetTaskParams) => ({
agentUrl: params.agentUrl,
taskId: params.taskId,
apiKey: params.apiKey,
historyLength: params.historyLength,
}),
},
transformResponse: async (response: Response) => {

View File

@@ -5,6 +5,7 @@ import { a2aGetPushNotificationTool } from './get_push_notification'
import { a2aGetTaskTool } from './get_task'
import { a2aResubscribeTool } from './resubscribe'
import { a2aSendMessageTool } from './send_message'
import { a2aSendMessageStreamTool } from './send_message_stream'
import { a2aSetPushNotificationTool } from './set_push_notification'
export {
@@ -15,5 +16,6 @@ export {
a2aGetTaskTool,
a2aResubscribeTool,
a2aSendMessageTool,
a2aSendMessageStreamTool,
a2aSetPushNotificationTool,
}

View File

@@ -30,14 +30,11 @@ export const a2aResubscribeTool: ToolConfig<A2AResubscribeParams, A2AResubscribe
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: A2AResubscribeParams) => {
const body: Record<string, string> = {
agentUrl: params.agentUrl,
taskId: params.taskId,
}
if (params.apiKey) body.apiKey = params.apiKey
return body
},
body: (params: A2AResubscribeParams) => ({
agentUrl: params.agentUrl,
taskId: params.taskId,
apiKey: params.apiKey,
}),
},
transformResponse: async (response) => {

View File

@@ -26,14 +26,6 @@ export const a2aSendMessageTool: ToolConfig<A2ASendMessageParams, A2ASendMessage
type: 'string',
description: 'Context ID for conversation continuity',
},
data: {
type: 'string',
description: 'Structured data to include with the message (JSON string)',
},
files: {
type: 'array',
description: 'Files to include with the message',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
@@ -43,21 +35,7 @@ export const a2aSendMessageTool: ToolConfig<A2ASendMessageParams, A2ASendMessage
request: {
url: '/api/tools/a2a/send-message',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, unknown> = {
agentUrl: params.agentUrl,
message: params.message,
}
if (params.taskId) body.taskId = params.taskId
if (params.contextId) body.contextId = params.contextId
if (params.data) body.data = params.data
if (params.files && params.files.length > 0) body.files = params.files
if (params.apiKey) body.apiKey = params.apiKey
return body
},
headers: () => ({}),
},
transformResponse: async (response: Response) => {

View File

@@ -0,0 +1,81 @@
import type { ToolConfig } from '@/tools/types'
import type { A2ASendMessageParams, A2ASendMessageResponse } from './types'
export const a2aSendMessageStreamTool: ToolConfig<A2ASendMessageParams, A2ASendMessageResponse> = {
id: 'a2a_send_message_stream',
name: 'A2A Send Message (Streaming)',
description: 'Send a message to an external A2A-compatible agent with real-time streaming.',
version: '1.0.0',
params: {
agentUrl: {
type: 'string',
required: true,
description: 'The A2A agent endpoint URL',
},
message: {
type: 'string',
required: true,
description: 'Message to send to the agent',
},
taskId: {
type: 'string',
description: 'Task ID for continuing an existing task',
},
contextId: {
type: 'string',
description: 'Context ID for conversation continuity',
},
apiKey: {
type: 'string',
description: 'API key for authentication',
},
},
request: {
url: '/api/tools/a2a/send-message-stream',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
agentUrl: params.agentUrl,
message: params.message,
taskId: params.taskId,
contextId: params.contextId,
apiKey: params.apiKey,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return data
},
outputs: {
content: {
type: 'string',
description: 'The text response from the agent',
},
taskId: {
type: 'string',
description: 'Task ID for follow-up interactions',
},
contextId: {
type: 'string',
description: 'Context ID for conversation continuity',
},
state: {
type: 'string',
description: 'Task state',
},
artifacts: {
type: 'array',
description: 'Structured output artifacts',
},
history: {
type: 'array',
description: 'Full message history',
},
},
}

View File

@@ -42,16 +42,13 @@ export const a2aSetPushNotificationTool: ToolConfig<
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: A2ASetPushNotificationParams) => {
const body: Record<string, string> = {
agentUrl: params.agentUrl,
taskId: params.taskId,
webhookUrl: params.webhookUrl,
}
if (params.token) body.token = params.token
if (params.apiKey) body.apiKey = params.apiKey
return body
},
body: (params: A2ASetPushNotificationParams) => ({
agentUrl: params.agentUrl,
taskId: params.taskId,
webhookUrl: params.webhookUrl,
token: params.token,
apiKey: params.apiKey,
}),
},
transformResponse: async (response: Response) => {

View File

@@ -25,20 +25,11 @@ export interface A2AGetAgentCardResponse extends ToolResponse {
}
}
export interface A2ASendMessageFileInput {
type: 'file' | 'url'
data: string
name: string
mime?: string
}
export interface A2ASendMessageParams {
agentUrl: string
message: string
taskId?: string
contextId?: string
data?: string
files?: A2ASendMessageFileInput[]
apiKey?: string
}

View File

@@ -5,6 +5,7 @@ import {
a2aGetPushNotificationTool,
a2aGetTaskTool,
a2aResubscribeTool,
a2aSendMessageStreamTool,
a2aSendMessageTool,
a2aSetPushNotificationTool,
} from '@/tools/a2a'
@@ -1179,8 +1180,6 @@ import {
slackCanvasTool,
slackDeleteMessageTool,
slackDownloadTool,
slackGetMessageTool,
slackGetThreadTool,
slackGetUserTool,
slackListChannelsTool,
slackListMembersTool,
@@ -1381,7 +1380,6 @@ import {
telegramSendVideoTool,
} from '@/tools/telegram'
import { thinkingTool } from '@/tools/thinking'
import { tinybirdEventsTool, tinybirdQueryTool } from '@/tools/tinybird'
import {
trelloAddCommentTool,
trelloCreateCardTool,
@@ -1543,6 +1541,7 @@ export const tools: Record<string, ToolConfig> = {
a2a_get_task: a2aGetTaskTool,
a2a_resubscribe: a2aResubscribeTool,
a2a_send_message: a2aSendMessageTool,
a2a_send_message_stream: a2aSendMessageStreamTool,
a2a_set_push_notification: a2aSetPushNotificationTool,
arxiv_search: arxivSearchTool,
arxiv_get_paper: arxivGetPaperTool,
@@ -1732,8 +1731,6 @@ export const tools: Record<string, ToolConfig> = {
slack_list_members: slackListMembersTool,
slack_list_users: slackListUsersTool,
slack_get_user: slackGetUserTool,
slack_get_message: slackGetMessageTool,
slack_get_thread: slackGetThreadTool,
slack_canvas: slackCanvasTool,
slack_download: slackDownloadTool,
slack_update_message: slackUpdateMessageTool,
@@ -2238,8 +2235,6 @@ export const tools: Record<string, ToolConfig> = {
apollo_email_accounts: apolloEmailAccountsTool,
mistral_parser: mistralParserTool,
thinking_tool: thinkingTool,
tinybird_events: tinybirdEventsTool,
tinybird_query: tinybirdQueryTool,
stagehand_extract: stagehandExtractTool,
stagehand_agent: stagehandAgentTool,
mem0_add_memories: mem0AddMemoriesTool,

View File

@@ -1,213 +0,0 @@
import type { SlackGetMessageParams, SlackGetMessageResponse } from '@/tools/slack/types'
import type { ToolConfig } from '@/tools/types'
export const slackGetMessageTool: ToolConfig<SlackGetMessageParams, SlackGetMessageResponse> = {
id: 'slack_get_message',
name: 'Slack Get Message',
description:
'Retrieve a specific message by its timestamp. Useful for getting a thread parent message.',
version: '1.0.0',
oauth: {
required: true,
provider: 'slack',
},
params: {
authMethod: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Authentication method: oauth or bot_token',
},
botToken: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Bot token for Custom Bot',
},
accessToken: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'OAuth access token or bot token for Slack API',
},
channel: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Slack channel ID (e.g., C1234567890)',
},
timestamp: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Message timestamp to retrieve (e.g., 1405894322.002768)',
},
},
request: {
url: (params: SlackGetMessageParams) => {
const url = new URL('https://slack.com/api/conversations.history')
url.searchParams.append('channel', params.channel?.trim() ?? '')
url.searchParams.append('oldest', params.timestamp?.trim() ?? '')
url.searchParams.append('limit', '1')
url.searchParams.append('inclusive', 'true')
return url.toString()
},
method: 'GET',
headers: (params: SlackGetMessageParams) => ({
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken || params.botToken}`,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.ok) {
if (data.error === 'missing_scope') {
throw new Error(
'Missing required permissions. Please reconnect your Slack account with the necessary scopes (channels:history, groups:history).'
)
}
if (data.error === 'invalid_auth') {
throw new Error('Invalid authentication. Please check your Slack credentials.')
}
if (data.error === 'channel_not_found') {
throw new Error('Channel not found. Please check the channel ID.')
}
throw new Error(data.error || 'Failed to get message from Slack')
}
const messages = data.messages || []
if (messages.length === 0) {
throw new Error('Message not found')
}
const msg = messages[0]
const message = {
type: msg.type ?? 'message',
ts: msg.ts,
text: msg.text ?? '',
user: msg.user ?? null,
bot_id: msg.bot_id ?? null,
username: msg.username ?? null,
channel: msg.channel ?? null,
team: msg.team ?? null,
thread_ts: msg.thread_ts ?? null,
parent_user_id: msg.parent_user_id ?? null,
reply_count: msg.reply_count ?? null,
reply_users_count: msg.reply_users_count ?? null,
latest_reply: msg.latest_reply ?? null,
subscribed: msg.subscribed ?? null,
last_read: msg.last_read ?? null,
unread_count: msg.unread_count ?? null,
subtype: msg.subtype ?? null,
reactions: msg.reactions ?? [],
is_starred: msg.is_starred ?? false,
pinned_to: msg.pinned_to ?? [],
files: (msg.files ?? []).map((f: any) => ({
id: f.id,
name: f.name,
mimetype: f.mimetype,
size: f.size,
url_private: f.url_private ?? null,
permalink: f.permalink ?? null,
mode: f.mode ?? null,
})),
attachments: msg.attachments ?? [],
blocks: msg.blocks ?? [],
edited: msg.edited ?? null,
permalink: msg.permalink ?? null,
}
return {
success: true,
output: {
message,
},
}
},
outputs: {
message: {
type: 'object',
description: 'The retrieved message object',
properties: {
type: { type: 'string', description: 'Message type' },
ts: { type: 'string', description: 'Message timestamp' },
text: { type: 'string', description: 'Message text content' },
user: { type: 'string', description: 'User ID who sent the message' },
bot_id: { type: 'string', description: 'Bot ID if sent by a bot', optional: true },
username: { type: 'string', description: 'Display username', optional: true },
channel: { type: 'string', description: 'Channel ID', optional: true },
team: { type: 'string', description: 'Team ID', optional: true },
thread_ts: { type: 'string', description: 'Thread parent timestamp', optional: true },
parent_user_id: { type: 'string', description: 'User ID of thread parent', optional: true },
reply_count: { type: 'number', description: 'Number of thread replies', optional: true },
reply_users_count: {
type: 'number',
description: 'Number of users who replied',
optional: true,
},
latest_reply: { type: 'string', description: 'Timestamp of latest reply', optional: true },
subtype: { type: 'string', description: 'Message subtype', optional: true },
reactions: {
type: 'array',
description: 'Array of reactions on this message',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Emoji name' },
count: { type: 'number', description: 'Number of reactions' },
users: {
type: 'array',
description: 'User IDs who reacted',
items: { type: 'string' },
},
},
},
},
is_starred: { type: 'boolean', description: 'Whether message is starred', optional: true },
pinned_to: {
type: 'array',
description: 'Channel IDs where message is pinned',
items: { type: 'string' },
optional: true,
},
files: {
type: 'array',
description: 'Files attached to message',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'File ID' },
name: { type: 'string', description: 'File name' },
mimetype: { type: 'string', description: 'MIME type' },
size: { type: 'number', description: 'File size in bytes' },
url_private: { type: 'string', description: 'Private download URL' },
permalink: { type: 'string', description: 'Permanent link to file' },
},
},
},
attachments: {
type: 'array',
description: 'Legacy attachments',
items: { type: 'object' },
},
blocks: { type: 'array', description: 'Block Kit blocks', items: { type: 'object' } },
edited: {
type: 'object',
description: 'Edit information if message was edited',
properties: {
user: { type: 'string', description: 'User ID who edited' },
ts: { type: 'string', description: 'Edit timestamp' },
},
optional: true,
},
permalink: { type: 'string', description: 'Permanent link to message', optional: true },
},
},
},
}

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