mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-15 09:58:06 -05:00
Compare commits
28 Commits
feat/group
...
feat/reord
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ea4a19de9 | ||
|
|
83ec55b944 | ||
|
|
a2df89ee29 | ||
|
|
e350a9c611 | ||
|
|
df54029344 | ||
|
|
2ffc55b92b | ||
|
|
4b145a89a9 | ||
|
|
7d4671674c | ||
|
|
468ec2ea81 | ||
|
|
78d6082235 | ||
|
|
d7e0d9ba43 | ||
|
|
51477c12cc | ||
|
|
a3535639f1 | ||
|
|
f0d22246a7 | ||
|
|
d5bd97de32 | ||
|
|
bd7009e316 | ||
|
|
4f04b1efea | ||
|
|
258e96d6b5 | ||
|
|
4b026ad54d | ||
|
|
f6b7c15dc4 | ||
|
|
70ed19fcdb | ||
|
|
d6e4c91e81 | ||
|
|
e3fa40af11 | ||
|
|
6e0055f847 | ||
|
|
ebbe67aae3 | ||
|
|
2b49d15ec8 | ||
|
|
3d037c9b74 | ||
|
|
7dc4919220 |
@@ -552,6 +552,53 @@ 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).
|
||||
@@ -649,6 +696,11 @@ 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
|
||||
|
||||
27
.github/workflows/i18n.yml
vendored
27
.github/workflows/i18n.yml
vendored
@@ -1,11 +1,10 @@
|
||||
name: 'Auto-translate Documentation'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ staging ]
|
||||
paths:
|
||||
- 'apps/docs/content/docs/en/**'
|
||||
- 'apps/docs/i18n.json'
|
||||
schedule:
|
||||
# Run every Sunday at midnight UTC
|
||||
- cron: '0 0 * * 0'
|
||||
workflow_dispatch: # Allow manual triggers
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -20,6 +19,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: staging
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -68,12 +68,11 @@ jobs:
|
||||
title: "feat(i18n): update translations"
|
||||
body: |
|
||||
## Summary
|
||||
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 }}
|
||||
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
|
||||
**Workflow**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
## Type of Change
|
||||
@@ -107,7 +106,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/staging-merge-${{ github.run_id }}
|
||||
branch: auto-translate/weekly-${{ github.run_id }}
|
||||
base: staging
|
||||
labels: |
|
||||
i18n
|
||||
@@ -145,6 +144,8 @@ 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
|
||||
@@ -153,7 +154,7 @@ jobs:
|
||||
run: |
|
||||
cd apps/docs
|
||||
echo "## Translation Status Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Triggered by merge to staging branch**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Weekly scheduled translation run**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
en_count=$(find content/docs/en -name "*.mdx" | wc -l)
|
||||
|
||||
@@ -1855,17 +1855,25 @@ 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 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 {...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>
|
||||
)
|
||||
}
|
||||
@@ -1889,6 +1897,19 @@ 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'>
|
||||
|
||||
@@ -107,6 +107,7 @@ import {
|
||||
SupabaseIcon,
|
||||
TavilyIcon,
|
||||
TelegramIcon,
|
||||
TinybirdIcon,
|
||||
TranslateIcon,
|
||||
TrelloIcon,
|
||||
TTSIcon,
|
||||
@@ -230,6 +231,8 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
supabase: SupabaseIcon,
|
||||
tavily: TavilyIcon,
|
||||
telegram: TelegramIcon,
|
||||
thinking: BrainIcon,
|
||||
tinybird: TinybirdIcon,
|
||||
translate: TranslateIcon,
|
||||
trello: TrelloIcon,
|
||||
tts: TTSIcon,
|
||||
|
||||
@@ -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.001 per execution
|
||||
**Base Execution Charge**: $0.005 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.4x pricing multiplier for Agent blocks:
|
||||
**Hosted Models** - Sim provides API keys with a 1.1x pricing multiplier for Agent blocks:
|
||||
|
||||
**OpenAI**
|
||||
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
**Anthropic**
|
||||
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
**Google**
|
||||
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
*The 1.4x multiplier covers infrastructure and API management costs.*
|
||||
*The 1.1x multiplier covers infrastructure and API management costs.*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -44,6 +44,8 @@ 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
|
||||
@@ -208,8 +210,3 @@ Delete the push notification webhook configuration for a task.
|
||||
| `success` | boolean | Whether deletion was successful |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `tools`
|
||||
- Type: `a2a`
|
||||
|
||||
@@ -49,8 +49,7 @@ Retrieves lead information by email address or lead ID.
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Lemlist API key |
|
||||
| `email` | string | No | Lead email address \(use either email or id\) |
|
||||
| `id` | string | No | Lead ID \(use either email or id\) |
|
||||
| `leadIdentifier` | string | Yes | Lead email address or lead ID |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -103,6 +103,8 @@
|
||||
"supabase",
|
||||
"tavily",
|
||||
"telegram",
|
||||
"thinking",
|
||||
"tinybird",
|
||||
"translate",
|
||||
"trello",
|
||||
"tts",
|
||||
|
||||
@@ -124,6 +124,45 @@ 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.
|
||||
|
||||
70
apps/docs/content/docs/en/tools/tinybird.mdx
Normal file
70
apps/docs/content/docs/en/tools/tinybird.mdx
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
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`
|
||||
@@ -14,6 +14,7 @@ const updateFolderSchema = z.object({
|
||||
color: z.string().optional(),
|
||||
isExpanded: z.boolean().optional(),
|
||||
parentId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
})
|
||||
|
||||
// PUT - Update a folder
|
||||
@@ -38,7 +39,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: `Validation failed: ${errorMessages}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const { name, color, isExpanded, parentId } = validationResult.data
|
||||
const { name, color, isExpanded, parentId, sortOrder } = validationResult.data
|
||||
|
||||
// Verify the folder exists
|
||||
const existingFolder = await db
|
||||
@@ -81,12 +82,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// Update the folder
|
||||
const updates: any = { updatedAt: new Date() }
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date() }
|
||||
if (name !== undefined) updates.name = name.trim()
|
||||
if (color !== undefined) updates.color = color
|
||||
if (isExpanded !== undefined) updates.isExpanded = isExpanded
|
||||
if (parentId !== undefined) updates.parentId = parentId || null
|
||||
if (sortOrder !== undefined) updates.sortOrder = sortOrder
|
||||
|
||||
const [updatedFolder] = await db
|
||||
.update(workflowFolder)
|
||||
|
||||
91
apps/sim/app/api/folders/reorder/route.ts
Normal file
91
apps/sim/app/api/folders/reorder/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('FolderReorderAPI')
|
||||
|
||||
const ReorderSchema = z.object({
|
||||
workspaceId: z.string(),
|
||||
updates: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
sortOrder: z.number().int().min(0),
|
||||
parentId: z.string().nullable().optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized folder reorder attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { workspaceId, updates } = ReorderSchema.parse(body)
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (!permission || permission === 'read') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const folderIds = updates.map((u) => u.id)
|
||||
const existingFolders = await db
|
||||
.select({ id: workflowFolder.id, workspaceId: workflowFolder.workspaceId })
|
||||
.from(workflowFolder)
|
||||
.where(inArray(workflowFolder.id, folderIds))
|
||||
|
||||
const validIds = new Set(
|
||||
existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id)
|
||||
)
|
||||
|
||||
const validUpdates = updates.filter((u) => validIds.has(u.id))
|
||||
|
||||
if (validUpdates.length === 0) {
|
||||
return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const update of validUpdates) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
sortOrder: update.sortOrder,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
if (update.parentId !== undefined) {
|
||||
updateData.parentId = update.parentId
|
||||
}
|
||||
await tx.update(workflowFolder).set(updateData).where(eq(workflowFolder.id, update.id))
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Reordered ${validUpdates.length} folders in workspace ${workspaceId}`
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, updated: validUpdates.length })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid folder reorder data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error reordering folders`, error)
|
||||
return NextResponse.json({ error: 'Failed to reorder folders' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { name, workspaceId, parentId, color } = body
|
||||
const { name, workspaceId, parentId, color, sortOrder: providedSortOrder } = body
|
||||
|
||||
if (!name || !workspaceId) {
|
||||
return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 })
|
||||
@@ -81,25 +81,26 @@ export async function POST(request: NextRequest) {
|
||||
// Generate a new ID
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
// Use transaction to ensure sortOrder consistency
|
||||
const newFolder = await db.transaction(async (tx) => {
|
||||
// Get the next sort order for the parent (or root level)
|
||||
// Consider all folders in the workspace, not just those created by current user
|
||||
const existingFolders = await tx
|
||||
.select({ sortOrder: workflowFolder.sortOrder })
|
||||
.from(workflowFolder)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowFolder.workspaceId, workspaceId),
|
||||
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
|
||||
let sortOrder: number
|
||||
if (providedSortOrder !== undefined) {
|
||||
sortOrder = providedSortOrder
|
||||
} else {
|
||||
const existingFolders = await tx
|
||||
.select({ sortOrder: workflowFolder.sortOrder })
|
||||
.from(workflowFolder)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowFolder.workspaceId, workspaceId),
|
||||
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(workflowFolder.sortOrder))
|
||||
.limit(1)
|
||||
.orderBy(desc(workflowFolder.sortOrder))
|
||||
.limit(1)
|
||||
|
||||
const nextSortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0
|
||||
sortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0
|
||||
}
|
||||
|
||||
// Insert the new folder within the same transaction
|
||||
const [folder] = await tx
|
||||
.insert(workflowFolder)
|
||||
.values({
|
||||
@@ -109,7 +110,7 @@ export async function POST(request: NextRequest) {
|
||||
workspaceId,
|
||||
parentId: parentId || null,
|
||||
color: color || '#6B7280',
|
||||
sortOrder: nextSortOrder,
|
||||
sortOrder,
|
||||
})
|
||||
.returning()
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Message, Task } from '@a2a-js/sdk'
|
||||
import type { DataPart, FilePart, Message, Part, Task, TextPart } from '@a2a-js/sdk'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
@@ -10,11 +10,20 @@ 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(),
|
||||
})
|
||||
|
||||
@@ -51,18 +60,100 @@ export async function POST(request: NextRequest) {
|
||||
hasContextId: !!validatedData.contextId,
|
||||
})
|
||||
|
||||
const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
|
||||
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 message: Message = {
|
||||
kind: 'message',
|
||||
messageId: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
parts: [{ kind: 'text', text: validatedData.message }],
|
||||
parts,
|
||||
...(validatedData.taskId && { taskId: validatedData.taskId }),
|
||||
...(validatedData.contextId && { contextId: validatedData.contextId }),
|
||||
}
|
||||
|
||||
const result = await client.sendMessage({ message })
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
if (result.kind === 'message') {
|
||||
const responseMessage = result as Message
|
||||
|
||||
@@ -20,6 +20,7 @@ const UpdateWorkflowSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
folderId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -438,12 +439,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: any = { updatedAt: new Date() }
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() }
|
||||
if (updates.name !== undefined) updateData.name = updates.name
|
||||
if (updates.description !== undefined) updateData.description = updates.description
|
||||
if (updates.color !== undefined) updateData.color = updates.color
|
||||
if (updates.folderId !== undefined) updateData.folderId = updates.folderId
|
||||
if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder
|
||||
|
||||
// Update the workflow
|
||||
const [updatedWorkflow] = await db
|
||||
|
||||
91
apps/sim/app/api/workflows/reorder/route.ts
Normal file
91
apps/sim/app/api/workflows/reorder/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkflowReorderAPI')
|
||||
|
||||
const ReorderSchema = z.object({
|
||||
workspaceId: z.string(),
|
||||
updates: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
sortOrder: z.number().int().min(0),
|
||||
folderId: z.string().nullable().optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized reorder attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { workspaceId, updates } = ReorderSchema.parse(body)
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (!permission || permission === 'read') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const workflowIds = updates.map((u) => u.id)
|
||||
const existingWorkflows = await db
|
||||
.select({ id: workflow.id, workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(inArray(workflow.id, workflowIds))
|
||||
|
||||
const validIds = new Set(
|
||||
existingWorkflows.filter((w) => w.workspaceId === workspaceId).map((w) => w.id)
|
||||
)
|
||||
|
||||
const validUpdates = updates.filter((u) => validIds.has(u.id))
|
||||
|
||||
if (validUpdates.length === 0) {
|
||||
return NextResponse.json({ error: 'No valid workflows to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const update of validUpdates) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
sortOrder: update.sortOrder,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
if (update.folderId !== undefined) {
|
||||
updateData.folderId = update.folderId
|
||||
}
|
||||
await tx.update(workflow).set(updateData).where(eq(workflow.id, update.id))
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Reordered ${validUpdates.length} workflows in workspace ${workspaceId}`
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, updated: validUpdates.length })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid reorder data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error reordering workflows`, error)
|
||||
return NextResponse.json({ error: 'Failed to reorder workflows' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { and, eq, isNull, max } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -17,6 +17,7 @@ const CreateWorkflowSchema = z.object({
|
||||
color: z.string().optional().default('#3972F6'),
|
||||
workspaceId: z.string().optional(),
|
||||
folderId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
})
|
||||
|
||||
// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId)
|
||||
@@ -89,7 +90,14 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { name, description, color, workspaceId, folderId } = CreateWorkflowSchema.parse(body)
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
workspaceId,
|
||||
folderId,
|
||||
sortOrder: providedSortOrder,
|
||||
} = CreateWorkflowSchema.parse(body)
|
||||
|
||||
if (workspaceId) {
|
||||
const workspacePermission = await getUserEntityPermissions(
|
||||
@@ -127,11 +135,28 @@ export async function POST(req: NextRequest) {
|
||||
// Silently fail
|
||||
})
|
||||
|
||||
let sortOrder: number
|
||||
if (providedSortOrder !== undefined) {
|
||||
sortOrder = providedSortOrder
|
||||
} else {
|
||||
const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId)
|
||||
const [maxResult] = await db
|
||||
.select({ maxOrder: max(workflow.sortOrder) })
|
||||
.from(workflow)
|
||||
.where(
|
||||
workspaceId
|
||||
? and(eq(workflow.workspaceId, workspaceId), folderCondition)
|
||||
: and(eq(workflow.userId, session.user.id), folderCondition)
|
||||
)
|
||||
sortOrder = (maxResult?.maxOrder ?? -1) + 1
|
||||
}
|
||||
|
||||
await db.insert(workflow).values({
|
||||
id: workflowId,
|
||||
userId: session.user.id,
|
||||
workspaceId: workspaceId || null,
|
||||
folderId: folderId || null,
|
||||
sortOrder,
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
@@ -152,6 +177,7 @@ export async function POST(req: NextRequest) {
|
||||
color,
|
||||
workspaceId,
|
||||
folderId,
|
||||
sortOrder,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
@@ -2,13 +2,6 @@ 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()
|
||||
|
||||
@@ -227,7 +220,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 when invitation expired', async () => {
|
||||
it('should redirect to error page with token preserved when invitation expired', async () => {
|
||||
const session = createSession({
|
||||
userId: mockUser.id,
|
||||
email: 'invited@example.com',
|
||||
@@ -250,12 +243,13 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=expired'
|
||||
const location = response.headers.get('location')
|
||||
expect(location).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=expired&token=token-abc123'
|
||||
)
|
||||
})
|
||||
|
||||
it('should redirect to error page when email mismatch', async () => {
|
||||
it('should redirect to error page with token preserved when email mismatch', async () => {
|
||||
const session = createSession({
|
||||
userId: mockUser.id,
|
||||
email: 'wrong@example.com',
|
||||
@@ -277,12 +271,13 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=email-mismatch'
|
||||
const location = response.headers.get('location')
|
||||
expect(location).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=email-mismatch&token=token-abc123'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return 404 when invitation not found', async () => {
|
||||
it('should return 404 when invitation not found (without token)', async () => {
|
||||
const session = createSession({ userId: mockUser.id, email: mockUser.email })
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
dbSelectResults = [[]]
|
||||
@@ -296,6 +291,189 @@ 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]', () => {
|
||||
|
||||
@@ -31,7 +31,6 @@ 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()))
|
||||
}
|
||||
@@ -51,8 +50,9 @@ export async function GET(
|
||||
|
||||
if (!invitation) {
|
||||
if (isAcceptFlow) {
|
||||
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
|
||||
return NextResponse.redirect(
|
||||
new URL(`/invite/${invitationId}?error=invalid-token`, getBaseUrl())
|
||||
new URL(`/invite/${invitationId}?error=invalid-token${tokenParam}`, getBaseUrl())
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
|
||||
@@ -60,8 +60,9 @@ 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`, getBaseUrl())
|
||||
new URL(`/invite/${invitation.id}?error=expired${tokenParam}`, getBaseUrl())
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 })
|
||||
@@ -75,17 +76,20 @@ 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`, getBaseUrl())
|
||||
new URL(`/invite/${invitation.id}?error=workspace-not-found${tokenParam}`, 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`, getBaseUrl())
|
||||
new URL(`/invite/${invitation.id}?error=already-processed${tokenParam}`, getBaseUrl())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,7 +104,7 @@ export async function GET(
|
||||
|
||||
if (!userData) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/invite/${invitation.id}?error=user-not-found`, getBaseUrl())
|
||||
new URL(`/invite/${invitation.id}?error=user-not-found${tokenParam}`, getBaseUrl())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,7 +112,7 @@ export async function GET(
|
||||
|
||||
if (!isValidMatch) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/invite/${invitation.id}?error=email-mismatch`, getBaseUrl())
|
||||
new URL(`/invite/${invitation.id}?error=email-mismatch${tokenParam}`, getBaseUrl())
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const logger = createLogger('Workspaces')
|
||||
|
||||
const createWorkspaceSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name is required'),
|
||||
skipDefaultWorkflow: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
// Get all workspaces for the current user
|
||||
@@ -63,9 +64,9 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { name } = createWorkspaceSchema.parse(await req.json())
|
||||
const { name, skipDefaultWorkflow } = createWorkspaceSchema.parse(await req.json())
|
||||
|
||||
const newWorkspace = await createWorkspace(session.user.id, name)
|
||||
const newWorkspace = await createWorkspace(session.user.id, name, skipDefaultWorkflow)
|
||||
|
||||
return NextResponse.json({ workspace: newWorkspace })
|
||||
} catch (error) {
|
||||
@@ -80,7 +81,7 @@ async function createDefaultWorkspace(userId: string, userName?: string | null)
|
||||
return createWorkspace(userId, workspaceName)
|
||||
}
|
||||
|
||||
async function createWorkspace(userId: string, name: string) {
|
||||
async function createWorkspace(userId: string, name: string, skipDefaultWorkflow = false) {
|
||||
const workspaceId = crypto.randomUUID()
|
||||
const workflowId = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
@@ -97,7 +98,6 @@ async function createWorkspace(userId: string, name: string) {
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
// Create admin permissions for the workspace owner
|
||||
await tx.insert(permissions).values({
|
||||
id: crypto.randomUUID(),
|
||||
entityType: 'workspace' as const,
|
||||
@@ -108,37 +108,41 @@ async function createWorkspace(userId: string, name: string) {
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
// Create initial workflow for the workspace (empty canvas)
|
||||
// Create the workflow
|
||||
await tx.insert(workflow).values({
|
||||
id: workflowId,
|
||||
userId,
|
||||
workspaceId,
|
||||
folderId: null,
|
||||
name: 'default-agent',
|
||||
description: 'Your first workflow - start building here!',
|
||||
color: '#3972F6',
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
variables: {},
|
||||
})
|
||||
if (!skipDefaultWorkflow) {
|
||||
await tx.insert(workflow).values({
|
||||
id: workflowId,
|
||||
userId,
|
||||
workspaceId,
|
||||
folderId: null,
|
||||
name: 'default-agent',
|
||||
description: 'Your first workflow - start building here!',
|
||||
color: '#3972F6',
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
variables: {},
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Created workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}`
|
||||
skipDefaultWorkflow
|
||||
? `Created workspace ${workspaceId} for user ${userId}`
|
||||
: `Created workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}`
|
||||
)
|
||||
})
|
||||
|
||||
const { workflowState } = buildDefaultWorkflowArtifacts()
|
||||
const seedResult = await saveWorkflowToNormalizedTables(workflowId, workflowState)
|
||||
if (!skipDefaultWorkflow) {
|
||||
const { workflowState } = buildDefaultWorkflowArtifacts()
|
||||
const seedResult = await saveWorkflowToNormalizedTables(workflowId, workflowState)
|
||||
|
||||
if (!seedResult.success) {
|
||||
throw new Error(seedResult.error || 'Failed to seed default workflow state')
|
||||
if (!seedResult.success) {
|
||||
throw new Error(seedResult.error || 'Failed to seed default workflow state')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create workspace ${workspaceId} with initial workflow:`, error)
|
||||
logger.error(`Failed to create workspace ${workspaceId}:`, error)
|
||||
throw error
|
||||
}
|
||||
|
||||
|
||||
@@ -178,23 +178,25 @@ 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(() => {
|
||||
@@ -203,7 +205,6 @@ 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',
|
||||
})
|
||||
@@ -220,7 +221,6 @@ 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,7 +229,6 @@ 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))
|
||||
@@ -254,13 +253,11 @@ 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)
|
||||
@@ -289,7 +286,6 @@ 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
|
||||
}
|
||||
@@ -316,7 +312,6 @@ 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) {
|
||||
@@ -325,7 +320,6 @@ 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: {
|
||||
@@ -347,7 +341,6 @@ export default function Invite() {
|
||||
return
|
||||
}
|
||||
|
||||
// Set the organization as active
|
||||
await client.organization.setActive({
|
||||
organizationId: orgId,
|
||||
})
|
||||
@@ -360,7 +353,6 @@ export default function Invite() {
|
||||
} catch (err: any) {
|
||||
logger.error('Error accepting invitation:', err)
|
||||
|
||||
// Reset accepted state on error
|
||||
setAccepted(false)
|
||||
|
||||
const errorCode = parseApiError(err)
|
||||
@@ -371,7 +363,9 @@ export default function Invite() {
|
||||
}
|
||||
|
||||
const getCallbackUrl = () => {
|
||||
return `/invite/${inviteId}${token && token !== inviteId ? `?token=${token}` : ''}`
|
||||
const effectiveToken =
|
||||
token || sessionStorage.getItem('inviteToken') || searchParams.get('token')
|
||||
return `/invite/${inviteId}${effectiveToken && effectiveToken !== inviteId ? `?token=${effectiveToken}` : ''}`
|
||||
}
|
||||
|
||||
if (!session?.user && !isPending) {
|
||||
@@ -435,7 +429,6 @@ export default function Invite() {
|
||||
if (error) {
|
||||
const callbackUrl = encodeURIComponent(getCallbackUrl())
|
||||
|
||||
// Special handling for already in organization
|
||||
if (error.code === 'already-in-organization') {
|
||||
return (
|
||||
<InviteLayout>
|
||||
@@ -463,7 +456,6 @@ export default function Invite() {
|
||||
)
|
||||
}
|
||||
|
||||
// Handle email mismatch - user needs to sign in with a different account
|
||||
if (error.code === 'email-mismatch') {
|
||||
return (
|
||||
<InviteLayout>
|
||||
@@ -490,7 +482,6 @@ export default function Invite() {
|
||||
)
|
||||
}
|
||||
|
||||
// Handle auth-related errors - prompt user to sign in
|
||||
if (error.requiresAuth) {
|
||||
return (
|
||||
<InviteLayout>
|
||||
@@ -518,7 +509,6 @@ export default function Invite() {
|
||||
)
|
||||
}
|
||||
|
||||
// Handle retryable errors
|
||||
const actions: Array<{
|
||||
label: string
|
||||
onClick: () => void
|
||||
@@ -550,7 +540,6 @@ export default function Invite() {
|
||||
)
|
||||
}
|
||||
|
||||
// Show success only if accepted AND no error
|
||||
if (accepted && !error) {
|
||||
return (
|
||||
<InviteLayout>
|
||||
|
||||
@@ -221,7 +221,9 @@ export function Chat() {
|
||||
exportChatCSV,
|
||||
} = useChatStore()
|
||||
|
||||
const { entries } = useTerminalConsoleStore()
|
||||
const hasConsoleHydrated = useTerminalConsoleStore((state) => state._hasHydrated)
|
||||
const entriesFromStore = useTerminalConsoleStore((state) => state.entries)
|
||||
const entries = hasConsoleHydrated ? entriesFromStore : []
|
||||
const { isExecuting } = useExecutionStore()
|
||||
const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution()
|
||||
const { data: session } = useSession()
|
||||
@@ -531,35 +533,6 @@ 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
|
||||
|
||||
@@ -8,7 +8,6 @@ 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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
|
||||
export { ContextPills } from './context-pills/context-pills'
|
||||
export { MentionMenu } from './mention-menu/mention-menu'
|
||||
export { type MentionFolderNav, MentionMenu } from './mention-menu/mention-menu'
|
||||
export { ModeSelector } from './mode-selector/mode-selector'
|
||||
export { ModelSelector } from './model-selector/model-selector'
|
||||
export { SlashMenu } from './slash-menu/slash-menu'
|
||||
export { type SlashFolderNav, SlashMenu } from './slash-menu/slash-menu'
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
'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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
@@ -9,147 +9,167 @@ 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'
|
||||
|
||||
/**
|
||||
* 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]
|
||||
export interface SlashFolderNav {
|
||||
isInFolder: boolean
|
||||
openWebFolder: () => void
|
||||
closeFolder: () => void
|
||||
}
|
||||
|
||||
interface SlashMenuProps {
|
||||
mentionMenu: ReturnType<typeof useMentionMenu>
|
||||
message: string
|
||||
onSelectCommand: (command: string) => void
|
||||
onFolderNavChange?: (nav: SlashFolderNav) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
function SlashMenuContent({
|
||||
mentionMenu,
|
||||
message,
|
||||
onSelectCommand,
|
||||
onFolderNavChange,
|
||||
}: SlashMenuProps) {
|
||||
const { currentFolder, openFolder, closeFolder } = usePopoverContext()
|
||||
|
||||
const {
|
||||
mentionMenuRef,
|
||||
menuListRef,
|
||||
getActiveSlashQueryAtPosition,
|
||||
getCaretPos,
|
||||
submenuActiveIndex,
|
||||
mentionActiveIndex,
|
||||
openSubmenuFor,
|
||||
setOpenSubmenuFor,
|
||||
setSubmenuActiveIndex,
|
||||
} = mentionMenu
|
||||
|
||||
/**
|
||||
* Get the current query string after /
|
||||
*/
|
||||
const caretPos = getCaretPos()
|
||||
|
||||
const currentQuery = useMemo(() => {
|
||||
const caretPos = getCaretPos()
|
||||
const active = getActiveSlashQueryAtPosition(caretPos, message)
|
||||
return active?.query.trim().toLowerCase() || ''
|
||||
}, [message, getCaretPos, getActiveSlashQueryAtPosition])
|
||||
}, [message, caretPos, getActiveSlashQueryAtPosition])
|
||||
|
||||
/**
|
||||
* Filter commands based on query (search across all commands when there's a query)
|
||||
*/
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!currentQuery) return null // Show folder view when no query
|
||||
return ALL_COMMANDS.filter((cmd) => cmd.label.toLowerCase().includes(currentQuery))
|
||||
if (!currentQuery) return null
|
||||
return ALL_SLASH_COMMANDS.filter(
|
||||
(cmd) =>
|
||||
cmd.id.toLowerCase().includes(currentQuery) ||
|
||||
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
|
||||
|
||||
// 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,
|
||||
useEffect(() => {
|
||||
if (onFolderNavChange) {
|
||||
onFolderNavChange({
|
||||
isInFolder,
|
||||
openWebFolder: () => {
|
||||
openFolder('web', 'Web')
|
||||
setSubmenuActiveIndex(0)
|
||||
},
|
||||
closeFolder: () => {
|
||||
closeFolder()
|
||||
setSubmenuActiveIndex(0)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const caretPos = getCaretPos()
|
||||
const caretViewport = getCaretViewport(textareaEl, caretPos, message)
|
||||
|
||||
// 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'
|
||||
|
||||
// Check if we're in folder navigation mode (no query, not in submenu)
|
||||
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
|
||||
}, [onFolderNavChange, isInFolder, openFolder, closeFolder, setSubmenuActiveIndex])
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={true}
|
||||
onOpenChange={() => {
|
||||
/* controlled externally */
|
||||
}}
|
||||
>
|
||||
<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, side } = useCaretViewport({
|
||||
textareaRef,
|
||||
message,
|
||||
caretPos,
|
||||
})
|
||||
|
||||
if (!caretViewport) return null
|
||||
|
||||
return (
|
||||
<Popover open={true} onOpenChange={() => {}}>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
style={{
|
||||
@@ -169,80 +189,18 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr
|
||||
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 />
|
||||
<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>
|
||||
<SlashMenuContent
|
||||
mentionMenu={mentionMenu}
|
||||
message={message}
|
||||
onSelectCommand={onSelectCommand}
|
||||
onFolderNavChange={onFolderNavChange}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@@ -1,42 +1,245 @@
|
||||
/**
|
||||
* Constants for user input component
|
||||
*/
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
/**
|
||||
* Mention menu options in order (matches visual render order)
|
||||
* Mention folder types
|
||||
*/
|
||||
export const MENTION_OPTIONS = [
|
||||
'Chats',
|
||||
'Workflows',
|
||||
'Knowledge',
|
||||
'Blocks',
|
||||
'Workflow Blocks',
|
||||
'Templates',
|
||||
'Logs',
|
||||
'Docs',
|
||||
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' },
|
||||
] 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
|
||||
|
||||
@@ -49,3 +252,18 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useCaretViewport } from './use-caret-viewport'
|
||||
export { useContextManagement } from './use-context-management'
|
||||
export { useFileAttachments } from './use-file-attachments'
|
||||
export { useMentionData } from './use-mention-data'
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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])
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
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 {
|
||||
@@ -35,53 +39,7 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
|
||||
*/
|
||||
const addContext = useCallback((context: ChatContext) => {
|
||||
setSelectedContexts((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
|
||||
if (isContextAlreadySelected(context, prev)) return prev
|
||||
return [...prev, context]
|
||||
})
|
||||
}, [])
|
||||
@@ -92,38 +50,7 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
|
||||
* @param contextToRemove - Context to remove
|
||||
*/
|
||||
const removeContext = useCallback((contextToRemove: ChatContext) => {
|
||||
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
|
||||
}
|
||||
})
|
||||
)
|
||||
setSelectedContexts((prev) => filterOutContext(prev, contextToRemove))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
|
||||
@@ -83,6 +83,36 @@ 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
|
||||
@@ -90,7 +120,7 @@ interface UseMentionDataProps {
|
||||
* @param props - Configuration including workflow and workspace IDs
|
||||
* @returns Mention data state and loading operations
|
||||
*/
|
||||
export function useMentionData(props: UseMentionDataProps) {
|
||||
export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
const { workflowId, workspaceId } = props
|
||||
|
||||
const { config, isBlockAllowed } = usePermissionConfig()
|
||||
@@ -104,7 +134,6 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
const [blocksList, setBlocksList] = useState<BlockItem[]>([])
|
||||
const [isLoadingBlocks, setIsLoadingBlocks] = useState(false)
|
||||
|
||||
// Reset blocks list when permission config changes
|
||||
useEffect(() => {
|
||||
setBlocksList([])
|
||||
}, [config.allowedIntegrations])
|
||||
@@ -118,12 +147,10 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
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 =
|
||||
@@ -131,7 +158,6 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
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) => {
|
||||
@@ -219,14 +245,6 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
}
|
||||
}, [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
|
||||
*/
|
||||
@@ -348,18 +366,6 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
}
|
||||
}, [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,
|
||||
@@ -379,11 +385,9 @@ export function useMentionData(props: UseMentionDataProps) {
|
||||
|
||||
// Operations
|
||||
ensurePastChatsLoaded,
|
||||
ensureWorkflowsLoaded,
|
||||
ensureKnowledgeLoaded,
|
||||
ensureBlocksLoaded,
|
||||
ensureTemplatesLoaded,
|
||||
ensureLogsLoaded,
|
||||
ensureWorkflowBlocksLoaded,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useCallback } from 'react'
|
||||
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 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 {
|
||||
@@ -11,12 +18,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
|
||||
@@ -26,6 +33,7 @@ export function useMentionInsertHandlers({
|
||||
workflowId,
|
||||
selectedContexts,
|
||||
onContextAdd,
|
||||
mentionFolderNav,
|
||||
}: UseMentionInsertHandlersProps) {
|
||||
const {
|
||||
replaceActiveMentionWith,
|
||||
@@ -36,342 +44,94 @@ export function useMentionInsertHandlers({
|
||||
} = mentionMenu
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Closes all menus and resets state
|
||||
*/
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
if (config.useInsertFallback) {
|
||||
if (!replaceActiveMentionWith(label)) {
|
||||
insertAtCursor(` @${label} `)
|
||||
}
|
||||
} else {
|
||||
replaceActiveMentionWith(label)
|
||||
}
|
||||
|
||||
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
|
||||
onContextAdd(context)
|
||||
closeMenus()
|
||||
}
|
||||
|
||||
replaceActiveMentionWith(label)
|
||||
onContextAdd(context)
|
||||
setShowMentionMenu(false)
|
||||
setOpenSubmenuFor(null)
|
||||
},
|
||||
[
|
||||
replaceActiveMentionWith,
|
||||
onContextAdd,
|
||||
setShowMentionMenu,
|
||||
setOpenSubmenuFor,
|
||||
isContextAlreadySelected,
|
||||
resetActiveMentionQuery,
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* 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)
|
||||
},
|
||||
[
|
||||
selectedContexts,
|
||||
replaceActiveMentionWith,
|
||||
insertAtCursor,
|
||||
onContextAdd,
|
||||
setShowMentionMenu,
|
||||
setOpenSubmenuFor,
|
||||
isContextAlreadySelected,
|
||||
resetActiveMentionQuery,
|
||||
closeMenus,
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Special handler for Docs (no item parameter, uses DOCS_CONFIG)
|
||||
*/
|
||||
const insertDocsMention = useCallback(() => {
|
||||
const label = 'Docs'
|
||||
const context = { kind: 'docs', label } as any
|
||||
const label = DOCS_CONFIG.getLabel()
|
||||
const context = DOCS_CONFIG.buildContext()
|
||||
|
||||
// Prevent duplicate insertion
|
||||
if (isContextAlreadySelected(context)) {
|
||||
// Clear the partial mention text before closing
|
||||
if (isContextAlreadySelected(context, selectedContexts)) {
|
||||
resetActiveMentionQuery()
|
||||
setShowMentionMenu(false)
|
||||
setOpenSubmenuFor(null)
|
||||
closeMenus()
|
||||
return
|
||||
}
|
||||
|
||||
if (!replaceActiveMentionWith(label)) insertAtCursor(` @${label} `)
|
||||
// Docs uses fallback insertion
|
||||
if (!replaceActiveMentionWith(label)) {
|
||||
insertAtCursor(` @${label} `)
|
||||
}
|
||||
|
||||
onContextAdd(context)
|
||||
setShowMentionMenu(false)
|
||||
setOpenSubmenuFor(null)
|
||||
closeMenus()
|
||||
}, [
|
||||
selectedContexts,
|
||||
replaceActiveMentionWith,
|
||||
insertAtCursor,
|
||||
onContextAdd,
|
||||
setShowMentionMenu,
|
||||
setOpenSubmenuFor,
|
||||
isContextAlreadySelected,
|
||||
resetActiveMentionQuery,
|
||||
closeMenus,
|
||||
])
|
||||
|
||||
return {
|
||||
insertPastChatMention,
|
||||
insertWorkflowMention,
|
||||
insertKnowledgeMention,
|
||||
insertBlockMention,
|
||||
insertWorkflowBlockMention,
|
||||
insertTemplateMention,
|
||||
insertLogMention,
|
||||
insertDocsMention,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,56 +1,19 @@
|
||||
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
|
||||
}
|
||||
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'
|
||||
|
||||
interface UseMentionKeyboardProps {
|
||||
/** Mention menu hook instance */
|
||||
@@ -59,37 +22,34 @@ interface UseMentionKeyboardProps {
|
||||
mentionData: ReturnType<typeof useMentionData>
|
||||
/** Callback to insert specific mention types */
|
||||
insertHandlers: {
|
||||
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
|
||||
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
|
||||
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,
|
||||
@@ -98,65 +58,101 @@ export function useMentionKeyboard({
|
||||
scrollActiveItemIntoView,
|
||||
} = mentionMenu
|
||||
|
||||
const {
|
||||
pastChats,
|
||||
workflows,
|
||||
knowledgeBases,
|
||||
blocksList,
|
||||
workflowBlocks,
|
||||
templatesList,
|
||||
logsList,
|
||||
ensurePastChatsLoaded,
|
||||
ensureWorkflowsLoaded,
|
||||
ensureKnowledgeLoaded,
|
||||
ensureBlocksLoaded,
|
||||
ensureWorkflowBlocksLoaded,
|
||||
ensureTemplatesLoaded,
|
||||
ensureLogsLoaded,
|
||||
} = mentionData
|
||||
const currentFolder = mentionFolderNav?.currentFolder ?? null
|
||||
const isInFolder = mentionFolderNav?.isInFolder ?? false
|
||||
|
||||
const {
|
||||
insertPastChatMention,
|
||||
insertWorkflowMention,
|
||||
insertKnowledgeMention,
|
||||
insertBlockMention,
|
||||
insertWorkflowBlockMention,
|
||||
insertTemplateMention,
|
||||
insertLogMention,
|
||||
insertDocsMention,
|
||||
} = insertHandlers
|
||||
/**
|
||||
* 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]
|
||||
)
|
||||
|
||||
/**
|
||||
* Build aggregated list matching the portal's ordering
|
||||
*/
|
||||
const buildAggregatedList = useCallback(
|
||||
(query: string) => {
|
||||
(query: string): Array<{ type: MentionFolderId | 'docs'; value: any }> => {
|
||||
const q = query.toLowerCase()
|
||||
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 })),
|
||||
]
|
||||
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
|
||||
},
|
||||
[pastChats, workflows, knowledgeBases, blocksList, workflowBlocks, templatesList, logsList]
|
||||
[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]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -169,143 +165,36 @@ export function useMentionKeyboard({
|
||||
e.preventDefault()
|
||||
const caretPos = getCaretPos()
|
||||
const active = getActiveMentionQueryAtPosition(caretPos)
|
||||
const mainQ = (!openSubmenuFor ? active?.query || '' : '').toLowerCase()
|
||||
const mainQ = (!isInFolder ? active?.query || '' : '').toLowerCase()
|
||||
const direction = e.key === 'ArrowDown' ? 'down' : 'up'
|
||||
|
||||
// When there's a query, we show aggregated filtered view (no folders)
|
||||
const showAggregatedView = mainQ.length > 0
|
||||
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
|
||||
})
|
||||
if (showAggregatedView && !isInFolder) {
|
||||
const aggregatedList = buildAggregatedList(mainQ)
|
||||
navigateItems(direction, aggregatedList.length, setSubmenuActiveIndex)
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle submenu navigation
|
||||
if (openSubmenuFor === 'Chats') {
|
||||
if (currentFolder && FOLDER_CONFIGS[currentFolder as MentionFolderId]) {
|
||||
const q = getSubmenuQuery().toLowerCase()
|
||||
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
|
||||
})
|
||||
const filtered = filterFolderItems(currentFolder as MentionFolderId, q)
|
||||
navigateItems(direction, filtered.length, setSubmenuActiveIndex)
|
||||
return true
|
||||
}
|
||||
|
||||
navigateItems(direction, ROOT_MENU_ITEM_COUNT, setMentionActiveIndex)
|
||||
return true
|
||||
},
|
||||
[
|
||||
showMentionMenu,
|
||||
openSubmenuFor,
|
||||
mentionActiveIndex,
|
||||
submenuActiveIndex,
|
||||
isInFolder,
|
||||
currentFolder,
|
||||
buildAggregatedList,
|
||||
pastChats,
|
||||
workflows,
|
||||
knowledgeBases,
|
||||
blocksList,
|
||||
workflowBlocks,
|
||||
templatesList,
|
||||
logsList,
|
||||
filterFolderItems,
|
||||
navigateItems,
|
||||
getCaretPos,
|
||||
getActiveMentionQueryAtPosition,
|
||||
getSubmenuQuery,
|
||||
scrollActiveItemIntoView,
|
||||
setMentionActiveIndex,
|
||||
setSubmenuActiveIndex,
|
||||
]
|
||||
@@ -316,65 +205,30 @@ export function useMentionKeyboard({
|
||||
*/
|
||||
const handleArrowRight = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!showMentionMenu || e.key !== 'ArrowRight') return false
|
||||
if (!showMentionMenu || e.key !== 'ArrowRight' || !mentionFolderNav) return false
|
||||
|
||||
const caretPos = getCaretPos()
|
||||
const active = getActiveMentionQueryAtPosition(caretPos)
|
||||
const mainQ = (active?.query || '').toLowerCase()
|
||||
const showAggregatedView = mainQ.length > 0
|
||||
|
||||
// Don't handle arrow right in aggregated view (user is filtering, not navigating folders)
|
||||
if (showAggregatedView) return false
|
||||
if (mainQ.length > 0) return false
|
||||
|
||||
e.preventDefault()
|
||||
const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ))
|
||||
const selected = filteredMain[mentionActiveIndex]
|
||||
|
||||
if (selected === 'Chats') {
|
||||
const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length
|
||||
if (isDocsSelected) {
|
||||
resetActiveMentionQuery()
|
||||
setOpenSubmenuFor('Chats')
|
||||
setSubmenuActiveIndex(0)
|
||||
insertHandlers.insertDocsMention()
|
||||
return true
|
||||
}
|
||||
|
||||
const selectedFolderId = FOLDER_ORDER[mentionActiveIndex]
|
||||
if (selectedFolderId) {
|
||||
const config = FOLDER_CONFIGS[selectedFolderId]
|
||||
resetActiveMentionQuery()
|
||||
mentionFolderNav.openFolder(selectedFolderId, config.title)
|
||||
setSubmenuQueryStart(getCaretPos())
|
||||
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()
|
||||
ensureFolderLoaded(selectedFolderId)
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -382,21 +236,13 @@ export function useMentionKeyboard({
|
||||
[
|
||||
showMentionMenu,
|
||||
mentionActiveIndex,
|
||||
openSubmenuFor,
|
||||
mentionFolderNav,
|
||||
getCaretPos,
|
||||
getActiveMentionQueryAtPosition,
|
||||
resetActiveMentionQuery,
|
||||
setOpenSubmenuFor,
|
||||
setSubmenuActiveIndex,
|
||||
setSubmenuQueryStart,
|
||||
ensurePastChatsLoaded,
|
||||
ensureWorkflowsLoaded,
|
||||
ensureKnowledgeLoaded,
|
||||
ensureBlocksLoaded,
|
||||
ensureWorkflowBlocksLoaded,
|
||||
ensureTemplatesLoaded,
|
||||
ensureLogsLoaded,
|
||||
insertDocsMention,
|
||||
ensureFolderLoaded,
|
||||
insertHandlers,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -407,16 +253,16 @@ export function useMentionKeyboard({
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!showMentionMenu || e.key !== 'ArrowLeft') return false
|
||||
|
||||
if (openSubmenuFor) {
|
||||
if (isInFolder && mentionFolderNav) {
|
||||
e.preventDefault()
|
||||
setOpenSubmenuFor(null)
|
||||
mentionFolderNav.closeFolder()
|
||||
setSubmenuQueryStart(null)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[showMentionMenu, openSubmenuFor, setOpenSubmenuFor, setSubmenuQueryStart]
|
||||
[showMentionMenu, isInFolder, mentionFolderNav, setSubmenuQueryStart]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -429,179 +275,74 @@ export function useMentionKeyboard({
|
||||
e.preventDefault()
|
||||
const caretPos = getCaretPos()
|
||||
const active = getActiveMentionQueryAtPosition(caretPos)
|
||||
const mainQ = (active?.query || '').toLowerCase()
|
||||
const mainQ = (!isInFolder ? active?.query || '' : '').toLowerCase()
|
||||
const showAggregatedView = mainQ.length > 0
|
||||
const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ))
|
||||
const selected = filteredMain[mentionActiveIndex]
|
||||
|
||||
// Handle selection in aggregated filtered view
|
||||
if (showAggregatedView && !openSubmenuFor) {
|
||||
if (showAggregatedView && !isInFolder) {
|
||||
const aggregated = buildAggregatedList(mainQ)
|
||||
const idx = Math.max(0, Math.min(submenuActiveIndex, aggregated.length - 1))
|
||||
const chosen = aggregated[idx]
|
||||
if (chosen) {
|
||||
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)
|
||||
if (chosen.type === 'docs') {
|
||||
insertHandlers.insertDocsMention()
|
||||
} else {
|
||||
const handler = insertHandlerMap[chosen.type]
|
||||
handler(chosen.value)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle folder navigation when no query
|
||||
if (!openSubmenuFor && selected === 'Chats') {
|
||||
resetActiveMentionQuery()
|
||||
setOpenSubmenuFor('Chats')
|
||||
setSubmenuActiveIndex(0)
|
||||
setSubmenuQueryStart(getCaretPos())
|
||||
void ensurePastChatsLoaded()
|
||||
} else if (openSubmenuFor === 'Chats') {
|
||||
if (isInFolder && currentFolder && FOLDER_CONFIGS[currentFolder as MentionFolderId]) {
|
||||
const folderId = currentFolder as MentionFolderId
|
||||
const q = getSubmenuQuery().toLowerCase()
|
||||
const filtered = pastChats.filter((c) => (c.title || 'New Chat').toLowerCase().includes(q))
|
||||
const filtered = filterFolderItems(folderId, q)
|
||||
if (filtered.length > 0) {
|
||||
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
|
||||
insertPastChatMention(chosen)
|
||||
const handler = insertHandlerMap[folderId]
|
||||
handler(chosen)
|
||||
setSubmenuQueryStart(null)
|
||||
}
|
||||
} else if (!openSubmenuFor && selected === 'Workflows') {
|
||||
return true
|
||||
}
|
||||
|
||||
const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length
|
||||
if (isDocsSelected) {
|
||||
resetActiveMentionQuery()
|
||||
setOpenSubmenuFor('Workflows')
|
||||
insertHandlers.insertDocsMention()
|
||||
return true
|
||||
}
|
||||
|
||||
const selectedFolderId = FOLDER_ORDER[mentionActiveIndex]
|
||||
if (selectedFolderId && mentionFolderNav) {
|
||||
const config = FOLDER_CONFIGS[selectedFolderId]
|
||||
resetActiveMentionQuery()
|
||||
mentionFolderNav.openFolder(selectedFolderId, config.title)
|
||||
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)
|
||||
}
|
||||
ensureFolderLoaded(selectedFolderId)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[
|
||||
showMentionMenu,
|
||||
openSubmenuFor,
|
||||
isInFolder,
|
||||
currentFolder,
|
||||
mentionActiveIndex,
|
||||
submenuActiveIndex,
|
||||
mentionFolderNav,
|
||||
buildAggregatedList,
|
||||
pastChats,
|
||||
workflows,
|
||||
knowledgeBases,
|
||||
blocksList,
|
||||
workflowBlocks,
|
||||
templatesList,
|
||||
logsList,
|
||||
filterFolderItems,
|
||||
insertHandlerMap,
|
||||
getCaretPos,
|
||||
getActiveMentionQueryAtPosition,
|
||||
getSubmenuQuery,
|
||||
resetActiveMentionQuery,
|
||||
setOpenSubmenuFor,
|
||||
setSubmenuActiveIndex,
|
||||
setSubmenuQueryStart,
|
||||
ensurePastChatsLoaded,
|
||||
ensureWorkflowsLoaded,
|
||||
ensureKnowledgeLoaded,
|
||||
ensureBlocksLoaded,
|
||||
ensureWorkflowBlocksLoaded,
|
||||
ensureTemplatesLoaded,
|
||||
ensureLogsLoaded,
|
||||
insertPastChatMention,
|
||||
insertWorkflowMention,
|
||||
insertKnowledgeMention,
|
||||
insertBlockMention,
|
||||
insertWorkflowBlockMention,
|
||||
insertTemplateMention,
|
||||
insertLogMention,
|
||||
insertDocsMention,
|
||||
ensureFolderLoaded,
|
||||
insertHandlers,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { SCROLL_TOLERANCE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { SCROLL_TOLERANCE } from '../constants'
|
||||
|
||||
const logger = createLogger('useMentionMenu')
|
||||
|
||||
interface UseMentionMenuProps {
|
||||
/** Current message text */
|
||||
|
||||
@@ -49,7 +49,6 @@ 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
|
||||
@@ -66,7 +65,6 @@ 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
|
||||
@@ -80,7 +78,6 @@ 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
|
||||
@@ -91,20 +88,17 @@ 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
|
||||
})
|
||||
@@ -119,25 +113,20 @@ 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'
|
||||
|
||||
@@ -146,22 +135,18 @@ 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])
|
||||
|
||||
@@ -192,19 +177,15 @@ 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()
|
||||
@@ -212,7 +193,6 @@ export function useTextareaAutoResize({
|
||||
textareaResizeObserverRef.current.observe(textarea)
|
||||
}
|
||||
|
||||
// Setup MutationObserver to detect style changes
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
syncOverlayStyles.current()
|
||||
})
|
||||
@@ -221,11 +201,9 @@ 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)
|
||||
|
||||
@@ -18,12 +18,21 @@ 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 { NEAR_TOP_THRESHOLD } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
|
||||
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 {
|
||||
useContextManagement,
|
||||
useFileAttachments,
|
||||
@@ -110,7 +119,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// Refs and external hooks
|
||||
const { data: session } = useSession()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -122,19 +130,18 @@ 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'
|
||||
@@ -143,11 +150,8 @@ 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,
|
||||
@@ -155,7 +159,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
onMessageChange: setMessage,
|
||||
})
|
||||
|
||||
// Mention token utilities
|
||||
const mentionTokensWithContext = useMentionTokens({
|
||||
message,
|
||||
selectedContexts: contextManagement.selectedContexts,
|
||||
@@ -183,22 +186,21 @@ 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,
|
||||
() => ({
|
||||
@@ -215,17 +217,6 @@ 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) {
|
||||
@@ -253,7 +244,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
}, [containerRef])
|
||||
|
||||
// Also check position when mention menu opens
|
||||
useEffect(() => {
|
||||
if (mentionMenu.showMentionMenu && containerRef) {
|
||||
const rect = containerRef.getBoundingClientRect()
|
||||
@@ -261,9 +251,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
}, [mentionMenu.showMentionMenu, containerRef])
|
||||
|
||||
// Preload mention data when query is active
|
||||
useEffect(() => {
|
||||
if (!mentionMenu.showMentionMenu || mentionMenu.openSubmenuFor) {
|
||||
if (!mentionMenu.showMentionMenu || mentionFolderNav?.isInFolder) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -273,38 +262,31 @@ 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()
|
||||
void mentionData.ensureWorkflowsLoaded()
|
||||
void mentionData.ensureWorkflowBlocksLoaded()
|
||||
// workflows and workflow-blocks auto-load from stores
|
||||
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, mentionMenu.openSubmenuFor, message])
|
||||
}, [mentionMenu.showMentionMenu, mentionFolderNav?.isInFolder, message])
|
||||
|
||||
// When switching into a submenu, select the first item and scroll to it
|
||||
useEffect(() => {
|
||||
if (mentionMenu.openSubmenuFor) {
|
||||
if (mentionFolderNav?.isInFolder) {
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mentionMenu.openSubmenuFor])
|
||||
}, [mentionFolderNav?.isInFolder])
|
||||
|
||||
// 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)
|
||||
@@ -377,17 +359,12 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
const handleSlashCommandSelect = useCallback(
|
||||
(command: string) => {
|
||||
// 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
|
||||
const displayLabel = getCommandDisplayLabel(command)
|
||||
mentionMenu.replaceActiveSlashWith(displayLabel)
|
||||
contextManagement.addContext({
|
||||
kind: 'slash_command',
|
||||
command,
|
||||
label: capitalizedCommand,
|
||||
label: displayLabel,
|
||||
})
|
||||
|
||||
setShowSlashMenu(false)
|
||||
@@ -398,12 +375,13 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Escape key handling
|
||||
if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) {
|
||||
e.preventDefault()
|
||||
if (mentionMenu.openSubmenuFor) {
|
||||
mentionMenu.setOpenSubmenuFor(null)
|
||||
if (mentionFolderNav?.isInFolder) {
|
||||
mentionFolderNav.closeFolder()
|
||||
mentionMenu.setSubmenuQueryStart(null)
|
||||
} else if (slashFolderNav?.isInFolder) {
|
||||
slashFolderNav.closeFolder()
|
||||
} else {
|
||||
mentionMenu.closeMentionMenu()
|
||||
setShowSlashMenu(false)
|
||||
@@ -411,65 +389,34 @@ 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 (mentionMenu.openSubmenuFor === 'Web') {
|
||||
// Navigate in Web submenu
|
||||
const last = WEB_COMMANDS.length - 1
|
||||
if (isInFolder) {
|
||||
mentionMenu.setSubmenuActiveIndex((prev) => {
|
||||
const next =
|
||||
e.key === 'ArrowDown'
|
||||
? prev >= last
|
||||
? 0
|
||||
: prev + 1
|
||||
: prev <= 0
|
||||
? last
|
||||
: prev - 1
|
||||
const next = getNextIndex(prev, direction, WEB_COMMANDS.length - 1)
|
||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
|
||||
return next
|
||||
})
|
||||
} else if (showAggregatedView) {
|
||||
// Navigate in filtered view
|
||||
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
|
||||
const last = Math.max(0, filtered.length - 1)
|
||||
const filtered = ALL_COMMAND_IDS.filter((cmd) => cmd.includes(query))
|
||||
mentionMenu.setSubmenuActiveIndex((prev) => {
|
||||
if (filtered.length === 0) return 0
|
||||
const next =
|
||||
e.key === 'ArrowDown'
|
||||
? prev >= last
|
||||
? 0
|
||||
: prev + 1
|
||||
: prev <= 0
|
||||
? last
|
||||
: prev - 1
|
||||
const next = getNextIndex(prev, direction, filtered.length - 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 =
|
||||
e.key === 'ArrowDown'
|
||||
? prev >= last
|
||||
? 0
|
||||
: prev + 1
|
||||
: prev <= 0
|
||||
? last
|
||||
: prev - 1
|
||||
const next = getNextIndex(prev, direction, TOP_LEVEL_COMMANDS.length)
|
||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
|
||||
return next
|
||||
})
|
||||
@@ -477,69 +424,54 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
return
|
||||
}
|
||||
|
||||
// Arrow right to enter Web submenu
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
if (!showAggregatedView && !mentionMenu.openSubmenuFor) {
|
||||
// Check if Web folder is selected (it's after all top-level commands)
|
||||
if (!showAggregatedView && !isInFolder) {
|
||||
if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) {
|
||||
mentionMenu.setOpenSubmenuFor('Web')
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
slashFolderNav?.openWebFolder()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Arrow left to exit submenu
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
if (mentionMenu.openSubmenuFor) {
|
||||
mentionMenu.setOpenSubmenuFor(null)
|
||||
if (isInFolder) {
|
||||
slashFolderNav?.closeFolder()
|
||||
}
|
||||
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 (mentionMenu.openSubmenuFor === 'Web') {
|
||||
// Select from Web submenu
|
||||
if (isInFolder) {
|
||||
const selectedCommand =
|
||||
WEB_COMMANDS[mentionMenu.submenuActiveIndex] || WEB_COMMANDS[0]
|
||||
WEB_COMMANDS[mentionMenu.submenuActiveIndex]?.id || WEB_COMMANDS[0].id
|
||||
handleSlashCommandSelect(selectedCommand)
|
||||
} else if (showAggregatedView) {
|
||||
// Select from filtered view
|
||||
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
|
||||
const filtered = ALL_COMMAND_IDS.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) {
|
||||
// Top-level command selected
|
||||
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex])
|
||||
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex].id)
|
||||
} else if (selectedIndex === TOP_LEVEL_COMMANDS.length) {
|
||||
// Web folder selected - open it
|
||||
mentionMenu.setOpenSubmenuFor('Web')
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
slashFolderNav?.openWebFolder()
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -552,7 +484,6 @@ 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
|
||||
@@ -561,11 +492,8 @@ 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'
|
||||
@@ -604,7 +532,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent typing inside token
|
||||
if (e.key.length === 1 || e.key === 'Space') {
|
||||
const blocked =
|
||||
selectionLength === 0 && !!mentionTokensWithContext.findRangeContaining(selStart)
|
||||
@@ -629,6 +556,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
message,
|
||||
mentionTokensWithContext,
|
||||
showSlashMenu,
|
||||
slashFolderNav,
|
||||
mentionFolderNav,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -637,21 +566,17 @@ 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 (mentionMenu.openSubmenuFor) {
|
||||
if (mentionFolderNav?.isInFolder) {
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
} else {
|
||||
mentionMenu.setMentionActiveIndex(0)
|
||||
@@ -670,7 +595,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
setShowSlashMenu(false)
|
||||
}
|
||||
},
|
||||
[setMessage, mentionMenu, disableMentions]
|
||||
[setMessage, mentionMenu, disableMentions, mentionFolderNav]
|
||||
)
|
||||
|
||||
const handleSelectAdjust = useCallback(() => {
|
||||
@@ -686,84 +611,66 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
}, [mentionMenu.textareaRef, mentionTokensWithContext])
|
||||
|
||||
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 insertTriggerAndOpenMenu = useCallback(
|
||||
(trigger: '@' | '/') => {
|
||||
if (disabled || isLoading) return
|
||||
const textarea = mentionMenu.textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
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()
|
||||
}, 0)
|
||||
const start = textarea.selectionStart ?? message.length
|
||||
const end = textarea.selectionEnd ?? message.length
|
||||
const needsSpaceBefore = start > 0 && !/\s/.test(message.charAt(start - 1))
|
||||
|
||||
mentionMenu.setShowMentionMenu(true)
|
||||
mentionMenu.setOpenSubmenuFor(null)
|
||||
mentionMenu.setMentionActiveIndex(0)
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
}, [disabled, isLoading, mentionMenu, message, setMessage])
|
||||
const insertText = needsSpaceBefore ? ` ${trigger}` : trigger
|
||||
const before = message.slice(0, start)
|
||||
const after = message.slice(end)
|
||||
setMessage(`${before}${insertText}${after}`)
|
||||
|
||||
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))
|
||||
setTimeout(() => {
|
||||
const newPos = before.length + insertText.length
|
||||
textarea.setSelectionRange(newPos, newPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
|
||||
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)
|
||||
if (trigger === '@') {
|
||||
mentionMenu.setShowMentionMenu(true)
|
||||
mentionMenu.setOpenSubmenuFor(null)
|
||||
mentionMenu.setMentionActiveIndex(0)
|
||||
} else {
|
||||
setShowSlashMenu(true)
|
||||
}
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
},
|
||||
[disabled, isLoading, mentionMenu, message, setMessage]
|
||||
)
|
||||
|
||||
setTimeout(() => {
|
||||
const newPos = before.length + insertText.length
|
||||
textarea.setSelectionRange(newPos, newPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
const handleOpenMentionMenuWithAt = useCallback(
|
||||
() => insertTriggerAndOpenMenu('@'),
|
||||
[insertTriggerAndOpenMenu]
|
||||
)
|
||||
|
||||
setShowSlashMenu(true)
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
}, [disabled, isLoading, mentionMenu, message, setMessage])
|
||||
const handleOpenSlashMenu = useCallback(
|
||||
() => insertTriggerAndOpenMenu('/'),
|
||||
[insertTriggerAndOpenMenu]
|
||||
)
|
||||
|
||||
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) {
|
||||
@@ -775,14 +682,11 @@ 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
|
||||
@@ -797,12 +701,10 @@ 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])
|
||||
|
||||
@@ -926,6 +828,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
mentionData={mentionData}
|
||||
message={message}
|
||||
insertHandlers={insertHandlers}
|
||||
onFolderNavChange={setMentionFolderNav}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
@@ -938,6 +841,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
mentionMenu={mentionMenu}
|
||||
message={message}
|
||||
onSelectCommand={handleSlashCommandSelect}
|
||||
onFolderNavChange={setSlashFolderNav}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -36,6 +36,7 @@ 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 {
|
||||
@@ -82,18 +83,6 @@ 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
|
||||
*/
|
||||
@@ -183,22 +172,6 @@ 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
|
||||
*/
|
||||
@@ -208,16 +181,25 @@ const formatRunId = (executionId?: string): string => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets color for a run ID based on its index in the execution ID order map
|
||||
* Run ID colors
|
||||
*/
|
||||
const getRunIdColor = (
|
||||
executionId: string | undefined,
|
||||
executionIdOrderMap: Map<string, number>
|
||||
) => {
|
||||
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>) => {
|
||||
if (!executionId) return null
|
||||
const colorIndex = executionIdOrderMap.get(executionId)
|
||||
if (colorIndex === undefined) return null
|
||||
return RUN_ID_COLORS[colorIndex % RUN_ID_COLORS.length]
|
||||
return colorMap.get(executionId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,12 +302,14 @@ 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 entries = useTerminalConsoleStore(useShallow(workflowEntriesSelector))
|
||||
const entriesFromStore = useTerminalConsoleStore(useShallow(workflowEntriesSelector))
|
||||
const entries = hasConsoleHydrated ? entriesFromStore : []
|
||||
const clearWorkflowConsole = useTerminalConsoleStore((state) => state.clearWorkflowConsole)
|
||||
const exportConsoleCSV = useTerminalConsoleStore((state) => state.exportConsoleCSV)
|
||||
const [selectedEntry, setSelectedEntry] = useState<ConsoleEntry | null>(null)
|
||||
@@ -462,25 +446,52 @@ export function Terminal() {
|
||||
}, [allWorkflowEntries])
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Track color offset - increments when old executions are trimmed
|
||||
* so remaining executions keep their colors.
|
||||
*/
|
||||
const executionIdOrderMap = useMemo(() => {
|
||||
const orderMap = new Map<string, number>()
|
||||
let colorIndex = 0
|
||||
const colorStateRef = useRef<{ executionIds: string[]; offset: number }>({
|
||||
executionIds: [],
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
// Process entries in reverse order (oldest first) since entries array is newest-first
|
||||
// Use allWorkflowEntries to ensure colors remain consistent when filters change
|
||||
/**
|
||||
* 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>()
|
||||
for (let i = allWorkflowEntries.length - 1; i >= 0; i--) {
|
||||
const entry = allWorkflowEntries[i]
|
||||
if (entry.executionId && !orderMap.has(entry.executionId)) {
|
||||
orderMap.set(entry.executionId, colorIndex)
|
||||
colorIndex++
|
||||
const execId = allWorkflowEntries[i].executionId
|
||||
if (execId && !seen.has(execId)) {
|
||||
currentIds.push(execId)
|
||||
seen.add(execId)
|
||||
}
|
||||
}
|
||||
|
||||
return orderMap
|
||||
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
|
||||
}, [allWorkflowEntries])
|
||||
|
||||
/**
|
||||
@@ -1126,7 +1137,7 @@ export function Terminal() {
|
||||
<PopoverScrollArea style={{ maxHeight: '140px' }}>
|
||||
{uniqueRunIds.map((runId, index) => {
|
||||
const isSelected = filters.runIds.has(runId)
|
||||
const runIdColor = getRunIdColor(runId, executionIdOrderMap)
|
||||
const runIdColor = getRunIdColor(runId, executionColorMap)
|
||||
|
||||
return (
|
||||
<PopoverItem
|
||||
@@ -1137,7 +1148,7 @@ export function Terminal() {
|
||||
>
|
||||
<span
|
||||
className='flex-1 font-mono text-[12px]'
|
||||
style={{ color: runIdColor?.text || '#D2D2D2' }}
|
||||
style={{ color: runIdColor || '#D2D2D2' }}
|
||||
>
|
||||
{formatRunId(runId)}
|
||||
</span>
|
||||
@@ -1333,7 +1344,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, executionIdOrderMap)
|
||||
const runIdColor = getRunIdColor(entry.executionId, executionColorMap)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1383,7 +1394,7 @@ export function Terminal() {
|
||||
COLUMN_BASE_CLASS,
|
||||
'truncate font-medium font-mono text-[12px]'
|
||||
)}
|
||||
style={{ color: runIdColor?.text || '#D2D2D2' }}
|
||||
style={{ color: runIdColor || '#D2D2D2' }}
|
||||
>
|
||||
{formatRunId(entry.executionId)}
|
||||
</span>
|
||||
@@ -1409,7 +1420,7 @@ export function Terminal() {
|
||||
ROW_TEXT_CLASS
|
||||
)}
|
||||
>
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
{formatTimeWithSeconds(new Date(entry.timestamp))}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -356,6 +356,9 @@ 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()
|
||||
@@ -2214,7 +2217,8 @@ const WorkflowContent = React.memo(() => {
|
||||
)
|
||||
|
||||
/**
|
||||
* Captures the source handle when a connection drag starts
|
||||
* Captures the source handle when a connection drag starts.
|
||||
* Resets connectionCompletedRef to track if onConnect handles this connection.
|
||||
*/
|
||||
const onConnectStart = useCallback((_event: any, params: any) => {
|
||||
const handleId: string | undefined = params?.handleId
|
||||
@@ -2223,6 +2227,7 @@ const WorkflowContent = React.memo(() => {
|
||||
nodeId: params?.nodeId,
|
||||
handleId: params?.handleId,
|
||||
}
|
||||
connectionCompletedRef.current = false
|
||||
}, [])
|
||||
|
||||
/** Handles new edge connections with container boundary validation. */
|
||||
@@ -2283,6 +2288,7 @@ const WorkflowContent = React.memo(() => {
|
||||
isInsideContainer: true,
|
||||
},
|
||||
})
|
||||
connectionCompletedRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2311,6 +2317,7 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
connectionCompletedRef.current = true
|
||||
}
|
||||
},
|
||||
[addEdge, getNodes, blocks]
|
||||
@@ -2319,8 +2326,9 @@ 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.
|
||||
* Only creates a connection if ReactFlow didn't already handle it (e.g., when
|
||||
* dropping on the block body instead of a handle).
|
||||
*
|
||||
* Uses connectionCompletedRef to check if onConnect already handled this connection
|
||||
* (ReactFlow pattern for distinguishing handle-to-handle vs handle-to-body drops).
|
||||
*/
|
||||
const onConnectEnd = useCallback(
|
||||
(event: MouseEvent | TouchEvent) => {
|
||||
@@ -2332,6 +2340,12 @@ 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({
|
||||
@@ -2342,25 +2356,14 @@ const WorkflowContent = React.memo(() => {
|
||||
// Find node under cursor
|
||||
const targetNode = findNodeAtPosition(flowPosition)
|
||||
|
||||
// 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).
|
||||
// Create connection if valid target found (handle-to-body case)
|
||||
if (targetNode && targetNode.id !== source.nodeId) {
|
||||
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',
|
||||
})
|
||||
}
|
||||
onConnect({
|
||||
source: source.nodeId,
|
||||
sourceHandle: source.handleId,
|
||||
target: targetNode.id,
|
||||
targetHandle: 'target',
|
||||
})
|
||||
}
|
||||
|
||||
connectionSourceRef.current = null
|
||||
|
||||
@@ -36,6 +36,8 @@ interface FolderItemProps {
|
||||
onDragEnter?: (e: React.DragEvent<HTMLElement>) => void
|
||||
onDragLeave?: (e: React.DragEvent<HTMLElement>) => void
|
||||
}
|
||||
onDragStart?: () => void
|
||||
onDragEnd?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,7 +48,13 @@ interface FolderItemProps {
|
||||
* @param props - Component props
|
||||
* @returns Folder item with drag and expand support
|
||||
*/
|
||||
export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
export function FolderItem({
|
||||
folder,
|
||||
level,
|
||||
hoverHandlers,
|
||||
onDragStart: onDragStartProp,
|
||||
onDragEnd: onDragEndProp,
|
||||
}: FolderItemProps) {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -135,11 +143,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
}
|
||||
}, [createFolderMutation, workspaceId, folder.id, expandFolder])
|
||||
|
||||
/**
|
||||
* Drag start handler - sets folder data for drag operation
|
||||
*
|
||||
* @param e - React drag event
|
||||
*/
|
||||
const onDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (isEditing) {
|
||||
@@ -149,14 +152,25 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
|
||||
e.dataTransfer.setData('folder-id', folder.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
onDragStartProp?.()
|
||||
},
|
||||
[folder.id]
|
||||
[folder.id, onDragStartProp]
|
||||
)
|
||||
|
||||
const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({
|
||||
const {
|
||||
isDragging,
|
||||
shouldPreventClickRef,
|
||||
handleDragStart,
|
||||
handleDragEnd: handleDragEndBase,
|
||||
} = useItemDrag({
|
||||
onDragStart,
|
||||
})
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
handleDragEndBase()
|
||||
onDragEndProp?.()
|
||||
}, [handleDragEndBase, onDragEndProp])
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
position,
|
||||
|
||||
@@ -29,6 +29,8 @@ interface WorkflowItemProps {
|
||||
active: boolean
|
||||
level: number
|
||||
onWorkflowClick: (workflowId: string, shiftKey: boolean, metaKey: boolean) => void
|
||||
onDragStart?: () => void
|
||||
onDragEnd?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,7 +40,14 @@ interface WorkflowItemProps {
|
||||
* @param props - Component props
|
||||
* @returns Workflow item with drag and selection support
|
||||
*/
|
||||
export function WorkflowItem({ workflow, active, level, onWorkflowClick }: WorkflowItemProps) {
|
||||
export function WorkflowItem({
|
||||
workflow,
|
||||
active,
|
||||
level,
|
||||
onWorkflowClick,
|
||||
onDragStart: onDragStartProp,
|
||||
onDragEnd: onDragEndProp,
|
||||
}: WorkflowItemProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { selectedWorkflows } = useFolderStore()
|
||||
@@ -104,30 +113,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
[workflow.id, updateWorkflow]
|
||||
)
|
||||
|
||||
/**
|
||||
* Drag start handler - handles workflow dragging with multi-selection support
|
||||
*
|
||||
* @param e - React drag event
|
||||
*/
|
||||
const onDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (isEditing) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const workflowIds =
|
||||
isSelected && selectedWorkflows.size > 1 ? Array.from(selectedWorkflows) : [workflow.id]
|
||||
|
||||
e.dataTransfer.setData('workflow-ids', JSON.stringify(workflowIds))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
},
|
||||
[isSelected, selectedWorkflows, workflow.id]
|
||||
)
|
||||
|
||||
const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({
|
||||
onDragStart,
|
||||
})
|
||||
const isEditingRef = useRef(false)
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
@@ -232,6 +218,43 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
itemId: workflow.id,
|
||||
})
|
||||
|
||||
isEditingRef.current = isEditing
|
||||
|
||||
const onDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (isEditingRef.current) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const currentSelection = useFolderStore.getState().selectedWorkflows
|
||||
const isCurrentlySelected = currentSelection.has(workflow.id)
|
||||
const workflowIds =
|
||||
isCurrentlySelected && currentSelection.size > 1
|
||||
? Array.from(currentSelection)
|
||||
: [workflow.id]
|
||||
|
||||
e.dataTransfer.setData('workflow-ids', JSON.stringify(workflowIds))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
onDragStartProp?.()
|
||||
},
|
||||
[workflow.id, onDragStartProp]
|
||||
)
|
||||
|
||||
const {
|
||||
isDragging,
|
||||
shouldPreventClickRef,
|
||||
handleDragStart,
|
||||
handleDragEnd: handleDragEndBase,
|
||||
} = useItemDrag({
|
||||
onDragStart,
|
||||
})
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
handleDragEndBase()
|
||||
onDragEndProp?.()
|
||||
}, [handleDragEndBase, onDragEndProp])
|
||||
|
||||
/**
|
||||
* Handle double-click on workflow name to enter rename mode
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { useParams, usePathname } from 'next/navigation'
|
||||
import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item'
|
||||
@@ -14,9 +14,6 @@ import { useFolderStore } from '@/stores/folders/store'
|
||||
import type { FolderTreeNode } from '@/stores/folders/types'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
|
||||
/**
|
||||
* Constants for tree layout and styling
|
||||
*/
|
||||
const TREE_SPACING = {
|
||||
INDENT_PER_LEVEL: 20,
|
||||
} as const
|
||||
@@ -29,12 +26,24 @@ interface WorkflowListProps {
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* WorkflowList component displays workflows organized by folders with drag-and-drop support.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Workflow list with folders and drag-drop support
|
||||
*/
|
||||
const DropIndicatorLine = memo(function DropIndicatorLine({
|
||||
show,
|
||||
level = 0,
|
||||
}: {
|
||||
show: boolean
|
||||
level?: number
|
||||
}) {
|
||||
if (!show) return null
|
||||
return (
|
||||
<div
|
||||
className='pointer-events-none absolute right-0 left-0 z-20 flex items-center'
|
||||
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
|
||||
>
|
||||
<div className='h-[2px] flex-1 rounded-full bg-[#33b4ff]/70' />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export function WorkflowList({
|
||||
regularWorkflows,
|
||||
isLoading = false,
|
||||
@@ -48,20 +57,21 @@ export function WorkflowList({
|
||||
const workflowId = params.workflowId as string
|
||||
|
||||
const { isLoading: foldersLoading } = useFolders(workspaceId)
|
||||
|
||||
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore()
|
||||
|
||||
const {
|
||||
dropTargetId,
|
||||
dropIndicator,
|
||||
isDragging,
|
||||
setScrollContainer,
|
||||
createWorkflowDragHandlers,
|
||||
createFolderDragHandlers,
|
||||
createItemDragHandlers,
|
||||
createRootDragHandlers,
|
||||
createFolderHeaderHoverHandlers,
|
||||
createEmptyFolderDropZone,
|
||||
createFolderContentDropZone,
|
||||
createRootDropZone,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
} = useDragDrop()
|
||||
|
||||
// Set scroll container when ref changes
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
setScrollContainer(scrollContainerRef.current)
|
||||
@@ -76,23 +86,22 @@ export function WorkflowList({
|
||||
return activeWorkflow?.folderId || null
|
||||
}, [workflowId, regularWorkflows, isLoading, foldersLoading])
|
||||
|
||||
const workflowsByFolder = useMemo(
|
||||
() =>
|
||||
regularWorkflows.reduce(
|
||||
(acc, workflow) => {
|
||||
const folderId = workflow.folderId || 'root'
|
||||
if (!acc[folderId]) acc[folderId] = []
|
||||
acc[folderId].push(workflow)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, WorkflowMetadata[]>
|
||||
),
|
||||
[regularWorkflows]
|
||||
)
|
||||
const workflowsByFolder = useMemo(() => {
|
||||
const grouped = regularWorkflows.reduce(
|
||||
(acc, workflow) => {
|
||||
const folderId = workflow.folderId || 'root'
|
||||
if (!acc[folderId]) acc[folderId] = []
|
||||
acc[folderId].push(workflow)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, WorkflowMetadata[]>
|
||||
)
|
||||
for (const folderId of Object.keys(grouped)) {
|
||||
grouped[folderId].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
}
|
||||
return grouped
|
||||
}, [regularWorkflows])
|
||||
|
||||
/**
|
||||
* Build a flat list of all workflow IDs in display order for range selection
|
||||
*/
|
||||
const orderedWorkflowIds = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
|
||||
@@ -106,12 +115,10 @@ export function WorkflowList({
|
||||
}
|
||||
}
|
||||
|
||||
// Collect from folders first
|
||||
for (const folder of folderTree) {
|
||||
collectWorkflowIds(folder)
|
||||
}
|
||||
|
||||
// Then collect root workflows
|
||||
const rootWorkflows = workflowsByFolder.root || []
|
||||
for (const workflow of rootWorkflows) {
|
||||
ids.push(workflow.id)
|
||||
@@ -120,30 +127,24 @@ export function WorkflowList({
|
||||
return ids
|
||||
}, [folderTree, workflowsByFolder])
|
||||
|
||||
// Workflow selection hook - uses active workflow ID as anchor for range selection
|
||||
const { handleWorkflowClick } = useWorkflowSelection({
|
||||
workflowIds: orderedWorkflowIds,
|
||||
activeWorkflowId: workflowId,
|
||||
})
|
||||
|
||||
const isWorkflowActive = useCallback(
|
||||
(workflowId: string) => pathname === `/workspace/${workspaceId}/w/${workflowId}`,
|
||||
(wfId: string) => pathname === `/workspace/${workspaceId}/w/${wfId}`,
|
||||
[pathname, workspaceId]
|
||||
)
|
||||
|
||||
/**
|
||||
* Auto-expand folders and select active workflow.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!workflowId || isLoading || foldersLoading) return
|
||||
|
||||
// Expand folder path to reveal workflow
|
||||
if (activeWorkflowFolderId) {
|
||||
const folderPath = getFolderPath(activeWorkflowFolderId)
|
||||
folderPath.forEach((folder) => setExpanded(folder.id, true))
|
||||
}
|
||||
|
||||
// Select workflow if not already selected
|
||||
const { selectedWorkflows, selectOnly } = useFolderStore.getState()
|
||||
if (!selectedWorkflows.has(workflowId)) {
|
||||
selectOnly(workflowId)
|
||||
@@ -151,23 +152,40 @@ export function WorkflowList({
|
||||
}, [workflowId, activeWorkflowFolderId, isLoading, foldersLoading, getFolderPath, setExpanded])
|
||||
|
||||
const renderWorkflowItem = useCallback(
|
||||
(workflow: WorkflowMetadata, level: number, parentFolderId: string | null = null) => (
|
||||
<div key={workflow.id} className='relative' {...createItemDragHandlers(parentFolderId)}>
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px`,
|
||||
}}
|
||||
>
|
||||
<WorkflowItem
|
||||
workflow={workflow}
|
||||
active={isWorkflowActive(workflow.id)}
|
||||
level={level}
|
||||
onWorkflowClick={handleWorkflowClick}
|
||||
/>
|
||||
(workflow: WorkflowMetadata, level: number, folderId: string | null = null) => {
|
||||
const showBefore =
|
||||
dropIndicator?.targetId === workflow.id && dropIndicator?.position === 'before'
|
||||
const showAfter =
|
||||
dropIndicator?.targetId === workflow.id && dropIndicator?.position === 'after'
|
||||
|
||||
return (
|
||||
<div key={workflow.id} className='relative'>
|
||||
<DropIndicatorLine show={showBefore} level={level} />
|
||||
<div
|
||||
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
|
||||
{...createWorkflowDragHandlers(workflow.id, folderId)}
|
||||
>
|
||||
<WorkflowItem
|
||||
workflow={workflow}
|
||||
active={isWorkflowActive(workflow.id)}
|
||||
level={level}
|
||||
onWorkflowClick={handleWorkflowClick}
|
||||
onDragStart={() => handleDragStart('workflow', folderId)}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
</div>
|
||||
<DropIndicatorLine show={showAfter} level={level} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
[isWorkflowActive, createItemDragHandlers, handleWorkflowClick]
|
||||
)
|
||||
},
|
||||
[
|
||||
dropIndicator,
|
||||
isWorkflowActive,
|
||||
createWorkflowDragHandlers,
|
||||
handleWorkflowClick,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
]
|
||||
)
|
||||
|
||||
const renderFolderSection = useCallback(
|
||||
@@ -179,45 +197,75 @@ export function WorkflowList({
|
||||
const workflowsInFolder = workflowsByFolder[folder.id] || []
|
||||
const isExpanded = expandedFolders.has(folder.id)
|
||||
const hasChildren = workflowsInFolder.length > 0 || folder.children.length > 0
|
||||
const isDropTarget = dropTargetId === folder.id
|
||||
|
||||
const showBefore =
|
||||
dropIndicator?.targetId === folder.id && dropIndicator?.position === 'before'
|
||||
const showAfter = dropIndicator?.targetId === folder.id && dropIndicator?.position === 'after'
|
||||
const showInside =
|
||||
dropIndicator?.targetId === folder.id && dropIndicator?.position === 'inside'
|
||||
|
||||
const childItems: Array<{
|
||||
type: 'folder' | 'workflow'
|
||||
id: string
|
||||
sortOrder: number
|
||||
data: FolderTreeNode | WorkflowMetadata
|
||||
}> = []
|
||||
for (const childFolder of folder.children) {
|
||||
childItems.push({
|
||||
type: 'folder',
|
||||
id: childFolder.id,
|
||||
sortOrder: childFolder.sortOrder,
|
||||
data: childFolder,
|
||||
})
|
||||
}
|
||||
for (const workflow of workflowsInFolder) {
|
||||
childItems.push({
|
||||
type: 'workflow',
|
||||
id: workflow.id,
|
||||
sortOrder: workflow.sortOrder,
|
||||
data: workflow,
|
||||
})
|
||||
}
|
||||
childItems.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
|
||||
return (
|
||||
<div key={folder.id} className='relative' {...createFolderDragHandlers(folder.id)}>
|
||||
{/* Drop target highlight overlay - always rendered for stable DOM */}
|
||||
<div key={folder.id} className='relative'>
|
||||
<DropIndicatorLine show={showBefore} level={level} />
|
||||
{/* Drop target highlight overlay - covers entire folder section */}
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
|
||||
isDropTarget && isDragging ? 'bg-gray-400/20 opacity-100' : 'opacity-0'
|
||||
showInside && isDragging ? 'bg-[#33b4ff1a] opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
|
||||
{...createItemDragHandlers(folder.id)}
|
||||
{...createFolderDragHandlers(folder.id, parentFolderId)}
|
||||
>
|
||||
<FolderItem
|
||||
folder={folder}
|
||||
level={level}
|
||||
hoverHandlers={createFolderHeaderHoverHandlers(folder.id)}
|
||||
onDragStart={() => handleDragStart('folder', parentFolderId)}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
</div>
|
||||
<DropIndicatorLine show={showAfter} level={level} />
|
||||
|
||||
{isExpanded && hasChildren && (
|
||||
<div className='relative' {...createItemDragHandlers(folder.id)}>
|
||||
{/* Vertical line - positioned to align under folder chevron */}
|
||||
{isExpanded && (hasChildren || isDragging) && (
|
||||
<div className='relative' {...createFolderContentDropZone(folder.id)}>
|
||||
<div
|
||||
className='pointer-events-none absolute top-0 bottom-0 w-px bg-[var(--border)]'
|
||||
style={{ left: `${level * TREE_SPACING.INDENT_PER_LEVEL + 12}px` }}
|
||||
/>
|
||||
<div className='mt-[2px] space-y-[2px] pl-[2px]'>
|
||||
{workflowsInFolder.map((workflow: WorkflowMetadata) =>
|
||||
renderWorkflowItem(workflow, level + 1, folder.id)
|
||||
{childItems.map((item) =>
|
||||
item.type === 'folder'
|
||||
? renderFolderSection(item.data as FolderTreeNode, level + 1, folder.id)
|
||||
: renderWorkflowItem(item.data as WorkflowMetadata, level + 1, folder.id)
|
||||
)}
|
||||
{!hasChildren && isDragging && (
|
||||
<div className='h-[24px]' {...createEmptyFolderDropZone(folder.id)} />
|
||||
)}
|
||||
{folder.children.map((childFolder) => (
|
||||
<div key={childFolder.id} className='relative'>
|
||||
{renderFolderSection(childFolder, level + 1, folder.id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -227,29 +275,47 @@ export function WorkflowList({
|
||||
[
|
||||
workflowsByFolder,
|
||||
expandedFolders,
|
||||
dropTargetId,
|
||||
dropIndicator,
|
||||
isDragging,
|
||||
createFolderDragHandlers,
|
||||
createItemDragHandlers,
|
||||
createFolderHeaderHoverHandlers,
|
||||
createEmptyFolderDropZone,
|
||||
createFolderContentDropZone,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
renderWorkflowItem,
|
||||
]
|
||||
)
|
||||
|
||||
const handleRootDragEvents = createRootDragHandlers()
|
||||
const rootDropZoneHandlers = createRootDropZone()
|
||||
const rootWorkflows = workflowsByFolder.root || []
|
||||
const isRootDropTarget = dropTargetId === 'root'
|
||||
const hasRootWorkflows = rootWorkflows.length > 0
|
||||
const hasFolders = folderTree.length > 0
|
||||
|
||||
/**
|
||||
* Handle click on empty space to revert to active workflow selection
|
||||
*/
|
||||
const rootItems = useMemo(() => {
|
||||
const items: Array<{
|
||||
type: 'folder' | 'workflow'
|
||||
id: string
|
||||
sortOrder: number
|
||||
data: FolderTreeNode | WorkflowMetadata
|
||||
}> = []
|
||||
for (const folder of folderTree) {
|
||||
items.push({ type: 'folder', id: folder.id, sortOrder: folder.sortOrder, data: folder })
|
||||
}
|
||||
for (const workflow of rootWorkflows) {
|
||||
items.push({
|
||||
type: 'workflow',
|
||||
id: workflow.id,
|
||||
sortOrder: workflow.sortOrder,
|
||||
data: workflow,
|
||||
})
|
||||
}
|
||||
return items.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
}, [folderTree, rootWorkflows])
|
||||
|
||||
const hasRootItems = rootItems.length > 0
|
||||
const showRootInside = dropIndicator?.targetId === 'root' && dropIndicator?.position === 'inside'
|
||||
|
||||
const handleContainerClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Only handle clicks directly on the container (empty space)
|
||||
if (e.target !== e.currentTarget) return
|
||||
|
||||
const { selectOnly, clearSelection } = useFolderStore.getState()
|
||||
workflowId ? selectOnly(workflowId) : clearSelection()
|
||||
},
|
||||
@@ -258,36 +324,23 @@ export function WorkflowList({
|
||||
|
||||
return (
|
||||
<div className='flex min-h-full flex-col pb-[8px]' onClick={handleContainerClick}>
|
||||
{/* Folders Section */}
|
||||
{hasFolders && (
|
||||
<div className='mb-[2px] space-y-[2px]'>
|
||||
{folderTree.map((folder) => renderFolderSection(folder, 0))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Root Workflows Section - Expands to fill remaining space */}
|
||||
<div
|
||||
className={clsx('relative flex-1', !hasRootWorkflows && 'min-h-[26px]')}
|
||||
{...handleRootDragEvents}
|
||||
className={clsx('relative flex-1 rounded-[4px]', !hasRootItems && 'min-h-[26px]')}
|
||||
{...rootDropZoneHandlers}
|
||||
>
|
||||
{/* Root drop target highlight overlay - always rendered for stable DOM */}
|
||||
{/* Root drop target highlight overlay */}
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
|
||||
isRootDropTarget && isDragging ? 'bg-gray-400/20 opacity-100' : 'opacity-0'
|
||||
showRootInside && isDragging ? 'bg-[#33b4ff1a] opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='space-y-[2px]'>
|
||||
{rootWorkflows.map((workflow: WorkflowMetadata) => (
|
||||
<WorkflowItem
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
active={isWorkflowActive(workflow.id)}
|
||||
level={0}
|
||||
onWorkflowClick={handleWorkflowClick}
|
||||
/>
|
||||
))}
|
||||
{rootItems.map((item) =>
|
||||
item.type === 'folder'
|
||||
? renderFolderSection(item.data as FolderTreeNode, 0, null)
|
||||
: renderWorkflowItem(item.data as WorkflowMetadata, 0, null)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { useAutoScroll } from './use-auto-scroll'
|
||||
export { useContextMenu } from './use-context-menu'
|
||||
export { useDragDrop } from './use-drag-drop'
|
||||
export { type DropIndicator, useDragDrop } from './use-drag-drop'
|
||||
export { useFolderExpand } from './use-folder-expand'
|
||||
export { useFolderOperations } from './use-folder-operations'
|
||||
export { useItemDrag } from './use-item-drag'
|
||||
|
||||
@@ -1,47 +1,40 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useUpdateFolder } from '@/hooks/queries/folders'
|
||||
import { useReorderFolders } from '@/hooks/queries/folders'
|
||||
import { useReorderWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('WorkflowList:DragDrop')
|
||||
|
||||
/**
|
||||
* Constants for auto-scroll behavior
|
||||
*/
|
||||
const SCROLL_THRESHOLD = 60 // Distance from edge to trigger scroll
|
||||
const SCROLL_SPEED = 8 // Pixels per frame
|
||||
const SCROLL_THRESHOLD = 60
|
||||
const SCROLL_SPEED = 8
|
||||
const HOVER_EXPAND_DELAY = 400
|
||||
|
||||
/**
|
||||
* Constants for folder auto-expand on hover during drag
|
||||
*/
|
||||
const HOVER_EXPAND_DELAY = 400 // Milliseconds to wait before expanding folder
|
||||
export interface DropIndicator {
|
||||
targetId: string
|
||||
position: 'before' | 'after' | 'inside'
|
||||
folderId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for handling drag and drop operations for workflows and folders.
|
||||
* Includes auto-scrolling, drop target highlighting, and hover-to-expand.
|
||||
*
|
||||
* @returns Drag and drop state and event handlers
|
||||
*/
|
||||
export function useDragDrop() {
|
||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||
const [dropIndicator, setDropIndicator] = useState<DropIndicator | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [hoverFolderId, setHoverFolderId] = useState<string | null>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const scrollIntervalRef = useRef<number | null>(null)
|
||||
const hoverExpandTimerRef = useRef<number | null>(null)
|
||||
const lastDragYRef = useRef<number>(0)
|
||||
const draggedTypeRef = useRef<'workflow' | 'folder' | null>(null)
|
||||
const draggedSourceFolderRef = useRef<string | null>(null)
|
||||
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string | undefined
|
||||
const updateFolderMutation = useUpdateFolder()
|
||||
const reorderWorkflowsMutation = useReorderWorkflows()
|
||||
const reorderFoldersMutation = useReorderFolders()
|
||||
const { setExpanded, expandedFolders } = useFolderStore()
|
||||
const { updateWorkflow } = useWorkflowRegistry()
|
||||
|
||||
/**
|
||||
* Auto-scroll handler - scrolls container when dragging near edges
|
||||
*/
|
||||
const handleAutoScroll = useCallback(() => {
|
||||
if (!scrollContainerRef.current || !isDragging) return
|
||||
|
||||
@@ -49,22 +42,17 @@ export function useDragDrop() {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const mouseY = lastDragYRef.current
|
||||
|
||||
// Only scroll if mouse is within container bounds
|
||||
if (mouseY < rect.top || mouseY > rect.bottom) return
|
||||
|
||||
// Calculate distance from top and bottom edges
|
||||
const distanceFromTop = mouseY - rect.top
|
||||
const distanceFromBottom = rect.bottom - mouseY
|
||||
|
||||
let scrollDelta = 0
|
||||
|
||||
// Scroll up if near top and not at scroll top
|
||||
if (distanceFromTop < SCROLL_THRESHOLD && container.scrollTop > 0) {
|
||||
const intensity = Math.max(0, Math.min(1, 1 - distanceFromTop / SCROLL_THRESHOLD))
|
||||
scrollDelta = -SCROLL_SPEED * intensity
|
||||
}
|
||||
// Scroll down if near bottom and not at scroll bottom
|
||||
else if (distanceFromBottom < SCROLL_THRESHOLD) {
|
||||
} else if (distanceFromBottom < SCROLL_THRESHOLD) {
|
||||
const maxScroll = container.scrollHeight - container.clientHeight
|
||||
if (container.scrollTop < maxScroll) {
|
||||
const intensity = Math.max(0, Math.min(1, 1 - distanceFromBottom / SCROLL_THRESHOLD))
|
||||
@@ -77,12 +65,9 @@ export function useDragDrop() {
|
||||
}
|
||||
}, [isDragging])
|
||||
|
||||
/**
|
||||
* Start auto-scroll animation loop
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
scrollIntervalRef.current = window.setInterval(handleAutoScroll, 10) // ~100fps for smoother response
|
||||
scrollIntervalRef.current = window.setInterval(handleAutoScroll, 10)
|
||||
} else {
|
||||
if (scrollIntervalRef.current) {
|
||||
clearInterval(scrollIntervalRef.current)
|
||||
@@ -97,30 +82,17 @@ export function useDragDrop() {
|
||||
}
|
||||
}, [isDragging, handleAutoScroll])
|
||||
|
||||
/**
|
||||
* Handle hover folder changes - start/clear expand timer
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Clear existing timer when hover folder changes
|
||||
if (hoverExpandTimerRef.current) {
|
||||
clearTimeout(hoverExpandTimerRef.current)
|
||||
hoverExpandTimerRef.current = null
|
||||
}
|
||||
|
||||
// Don't start timer if not dragging or no folder is hovered
|
||||
if (!isDragging || !hoverFolderId) {
|
||||
return
|
||||
}
|
||||
if (!isDragging || !hoverFolderId) return
|
||||
if (expandedFolders.has(hoverFolderId)) return
|
||||
|
||||
// Don't expand if folder is already expanded
|
||||
if (expandedFolders.has(hoverFolderId)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Start timer to expand folder after delay
|
||||
hoverExpandTimerRef.current = window.setTimeout(() => {
|
||||
setExpanded(hoverFolderId, true)
|
||||
logger.info(`Auto-expanded folder ${hoverFolderId} during drag`)
|
||||
}, HOVER_EXPAND_DELAY)
|
||||
|
||||
return () => {
|
||||
@@ -131,249 +103,471 @@ export function useDragDrop() {
|
||||
}
|
||||
}, [hoverFolderId, isDragging, expandedFolders, setExpanded])
|
||||
|
||||
/**
|
||||
* Cleanup hover state when dragging stops
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
setHoverFolderId(null)
|
||||
setDropIndicator(null)
|
||||
draggedTypeRef.current = null
|
||||
}
|
||||
}, [isDragging])
|
||||
|
||||
/**
|
||||
* Moves one or more workflows to a target folder
|
||||
*
|
||||
* @param workflowIds - Array of workflow IDs to move
|
||||
* @param targetFolderId - Target folder ID or null for root
|
||||
*/
|
||||
const handleWorkflowDrop = useCallback(
|
||||
async (workflowIds: string[], targetFolderId: string | null) => {
|
||||
if (!workflowIds.length) {
|
||||
logger.warn('No workflows to move')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
workflowIds.map((workflowId) => updateWorkflow(workflowId, { folderId: targetFolderId }))
|
||||
)
|
||||
logger.info(`Moved ${workflowIds.length} workflow(s)`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to move workflows:', error)
|
||||
}
|
||||
const calculateDropPosition = useCallback(
|
||||
(e: React.DragEvent, element: HTMLElement): 'before' | 'after' => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const midY = rect.top + rect.height / 2
|
||||
return e.clientY < midY ? 'before' : 'after'
|
||||
},
|
||||
[updateWorkflow]
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Moves a folder to a new parent folder, with validation
|
||||
*
|
||||
* @param draggedFolderId - ID of the folder being moved
|
||||
* @param targetFolderId - Target folder ID or null for root
|
||||
*/
|
||||
const handleFolderMove = useCallback(
|
||||
async (draggedFolderId: string, targetFolderId: string | null) => {
|
||||
if (!draggedFolderId) {
|
||||
logger.warn('No folder to move')
|
||||
return
|
||||
const calculateFolderDropPosition = useCallback(
|
||||
(e: React.DragEvent, element: HTMLElement): 'before' | 'inside' | 'after' => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const relativeY = e.clientY - rect.top
|
||||
const height = rect.height
|
||||
// Top 25% = before, middle 50% = inside, bottom 25% = after
|
||||
if (relativeY < height * 0.25) return 'before'
|
||||
if (relativeY > height * 0.75) return 'after'
|
||||
return 'inside'
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
type SiblingItem = { type: 'folder' | 'workflow'; id: string; sortOrder: number }
|
||||
|
||||
const getDestinationFolderId = useCallback((indicator: DropIndicator): string | null => {
|
||||
return indicator.position === 'inside'
|
||||
? indicator.targetId === 'root'
|
||||
? null
|
||||
: indicator.targetId
|
||||
: indicator.folderId
|
||||
}, [])
|
||||
|
||||
const calculateInsertIndex = useCallback(
|
||||
(remaining: SiblingItem[], indicator: DropIndicator): number => {
|
||||
return indicator.position === 'inside'
|
||||
? remaining.length
|
||||
: remaining.findIndex((item) => item.id === indicator.targetId) +
|
||||
(indicator.position === 'after' ? 1 : 0)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const buildAndSubmitUpdates = useCallback(
|
||||
async (newOrder: SiblingItem[], destinationFolderId: string | null) => {
|
||||
const indexed = newOrder.map((item, i) => ({ ...item, sortOrder: i }))
|
||||
|
||||
const folderUpdates = indexed
|
||||
.filter((item) => item.type === 'folder')
|
||||
.map((item) => ({ id: item.id, sortOrder: item.sortOrder, parentId: destinationFolderId }))
|
||||
|
||||
const workflowUpdates = indexed
|
||||
.filter((item) => item.type === 'workflow')
|
||||
.map((item) => ({ id: item.id, sortOrder: item.sortOrder, folderId: destinationFolderId }))
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
folderUpdates.length > 0 &&
|
||||
reorderFoldersMutation.mutateAsync({
|
||||
workspaceId: workspaceId!,
|
||||
updates: folderUpdates,
|
||||
}),
|
||||
workflowUpdates.length > 0 &&
|
||||
reorderWorkflowsMutation.mutateAsync({
|
||||
workspaceId: workspaceId!,
|
||||
updates: workflowUpdates,
|
||||
}),
|
||||
].filter(Boolean)
|
||||
)
|
||||
},
|
||||
[workspaceId, reorderFoldersMutation, reorderWorkflowsMutation]
|
||||
)
|
||||
|
||||
const isLeavingElement = useCallback((e: React.DragEvent<HTMLElement>): boolean => {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||
const currentTarget = e.currentTarget as HTMLElement
|
||||
return !relatedTarget || !currentTarget.contains(relatedTarget)
|
||||
}, [])
|
||||
|
||||
const initDragOver = useCallback((e: React.DragEvent<HTMLElement>, stopPropagation = true) => {
|
||||
e.preventDefault()
|
||||
if (stopPropagation) e.stopPropagation()
|
||||
lastDragYRef.current = e.clientY
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const getSiblingItems = useCallback((folderId: string | null): SiblingItem[] => {
|
||||
const currentFolders = useFolderStore.getState().folders
|
||||
const currentWorkflows = useWorkflowRegistry.getState().workflows
|
||||
return [
|
||||
...Object.values(currentFolders)
|
||||
.filter((f) => f.parentId === folderId)
|
||||
.map((f) => ({ type: 'folder' as const, id: f.id, sortOrder: f.sortOrder })),
|
||||
...Object.values(currentWorkflows)
|
||||
.filter((w) => w.folderId === folderId)
|
||||
.map((w) => ({ type: 'workflow' as const, id: w.id, sortOrder: w.sortOrder })),
|
||||
].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
}, [])
|
||||
|
||||
const setNormalizedDropIndicator = useCallback(
|
||||
(indicator: DropIndicator | null) => {
|
||||
setDropIndicator((prev) => {
|
||||
let next: DropIndicator | null = indicator
|
||||
|
||||
if (indicator && indicator.position === 'after' && indicator.targetId !== 'root') {
|
||||
const siblings = getSiblingItems(indicator.folderId)
|
||||
const currentIdx = siblings.findIndex((s) => s.id === indicator.targetId)
|
||||
const nextSibling = siblings[currentIdx + 1]
|
||||
if (nextSibling) {
|
||||
next = {
|
||||
targetId: nextSibling.id,
|
||||
position: 'before',
|
||||
folderId: indicator.folderId,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
prev?.targetId === next?.targetId &&
|
||||
prev?.position === next?.position &&
|
||||
prev?.folderId === next?.folderId
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
},
|
||||
[getSiblingItems]
|
||||
)
|
||||
|
||||
const isNoOpMove = useCallback(
|
||||
(
|
||||
indicator: DropIndicator,
|
||||
draggedIds: string[],
|
||||
draggedType: 'folder' | 'workflow',
|
||||
destinationFolderId: string | null,
|
||||
currentFolderId: string | null | undefined
|
||||
): boolean => {
|
||||
if (indicator.position !== 'inside' && draggedIds.includes(indicator.targetId)) {
|
||||
return true
|
||||
}
|
||||
if (currentFolderId !== destinationFolderId) {
|
||||
return false
|
||||
}
|
||||
const siblingItems = getSiblingItems(destinationFolderId)
|
||||
const remaining = siblingItems.filter(
|
||||
(item) => !(item.type === draggedType && draggedIds.includes(item.id))
|
||||
)
|
||||
const insertAt = calculateInsertIndex(remaining, indicator)
|
||||
const originalIdx = siblingItems.findIndex(
|
||||
(item) => item.type === draggedType && item.id === draggedIds[0]
|
||||
)
|
||||
return insertAt === originalIdx
|
||||
},
|
||||
[getSiblingItems, calculateInsertIndex]
|
||||
)
|
||||
|
||||
const handleWorkflowDrop = useCallback(
|
||||
async (workflowIds: string[], indicator: DropIndicator) => {
|
||||
if (!workflowIds.length || !workspaceId) return
|
||||
|
||||
try {
|
||||
const folderStore = useFolderStore.getState()
|
||||
const draggedFolderPath = folderStore.getFolderPath(draggedFolderId)
|
||||
const destinationFolderId = getDestinationFolderId(indicator)
|
||||
const currentWorkflows = useWorkflowRegistry.getState().workflows
|
||||
const firstWorkflow = currentWorkflows[workflowIds[0]]
|
||||
|
||||
// Prevent moving folder into its own descendant
|
||||
if (
|
||||
targetFolderId &&
|
||||
draggedFolderPath.some((ancestor) => ancestor.id === targetFolderId)
|
||||
isNoOpMove(
|
||||
indicator,
|
||||
workflowIds,
|
||||
'workflow',
|
||||
destinationFolderId,
|
||||
firstWorkflow?.folderId
|
||||
)
|
||||
) {
|
||||
logger.info('Cannot move folder into its own descendant')
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent moving folder into itself
|
||||
if (draggedFolderId === targetFolderId) {
|
||||
const siblingItems = getSiblingItems(destinationFolderId)
|
||||
const movingSet = new Set(workflowIds)
|
||||
const remaining = siblingItems.filter(
|
||||
(item) => !(item.type === 'workflow' && movingSet.has(item.id))
|
||||
)
|
||||
const moving = workflowIds
|
||||
.map((id) => ({
|
||||
type: 'workflow' as const,
|
||||
id,
|
||||
sortOrder: currentWorkflows[id]?.sortOrder ?? 0,
|
||||
}))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
|
||||
const insertAt = calculateInsertIndex(remaining, indicator)
|
||||
|
||||
const newOrder: SiblingItem[] = [
|
||||
...remaining.slice(0, insertAt),
|
||||
...moving,
|
||||
...remaining.slice(insertAt),
|
||||
]
|
||||
|
||||
await buildAndSubmitUpdates(newOrder, destinationFolderId)
|
||||
} catch (error) {
|
||||
logger.error('Failed to reorder workflows:', error)
|
||||
}
|
||||
},
|
||||
[
|
||||
getDestinationFolderId,
|
||||
getSiblingItems,
|
||||
calculateInsertIndex,
|
||||
isNoOpMove,
|
||||
buildAndSubmitUpdates,
|
||||
]
|
||||
)
|
||||
|
||||
const handleFolderDrop = useCallback(
|
||||
async (draggedFolderId: string, indicator: DropIndicator) => {
|
||||
if (!draggedFolderId || !workspaceId) return
|
||||
|
||||
try {
|
||||
const folderStore = useFolderStore.getState()
|
||||
const currentFolders = folderStore.folders
|
||||
|
||||
const targetParentId = getDestinationFolderId(indicator)
|
||||
|
||||
if (draggedFolderId === targetParentId) {
|
||||
logger.info('Cannot move folder into itself')
|
||||
return
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.warn('No workspaceId available for folder move')
|
||||
if (targetParentId) {
|
||||
const targetPath = folderStore.getFolderPath(targetParentId)
|
||||
if (targetPath.some((f) => f.id === draggedFolderId)) {
|
||||
logger.info('Cannot move folder into its own descendant')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const draggedFolder = currentFolders[draggedFolderId]
|
||||
if (
|
||||
isNoOpMove(
|
||||
indicator,
|
||||
[draggedFolderId],
|
||||
'folder',
|
||||
targetParentId,
|
||||
draggedFolder?.parentId
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
await updateFolderMutation.mutateAsync({
|
||||
workspaceId,
|
||||
id: draggedFolderId,
|
||||
updates: { parentId: targetFolderId },
|
||||
})
|
||||
logger.info(`Moved folder to ${targetFolderId ? `folder ${targetFolderId}` : 'root'}`)
|
||||
|
||||
const siblingItems = getSiblingItems(targetParentId)
|
||||
const remaining = siblingItems.filter(
|
||||
(item) => !(item.type === 'folder' && item.id === draggedFolderId)
|
||||
)
|
||||
|
||||
const insertAt = calculateInsertIndex(remaining, indicator)
|
||||
|
||||
const newOrder: SiblingItem[] = [
|
||||
...remaining.slice(0, insertAt),
|
||||
{ type: 'folder', id: draggedFolderId, sortOrder: 0 },
|
||||
...remaining.slice(insertAt),
|
||||
]
|
||||
|
||||
await buildAndSubmitUpdates(newOrder, targetParentId)
|
||||
} catch (error) {
|
||||
logger.error('Failed to move folder:', error)
|
||||
logger.error('Failed to reorder folder:', error)
|
||||
}
|
||||
},
|
||||
[updateFolderMutation, workspaceId]
|
||||
[
|
||||
workspaceId,
|
||||
getDestinationFolderId,
|
||||
getSiblingItems,
|
||||
calculateInsertIndex,
|
||||
isNoOpMove,
|
||||
buildAndSubmitUpdates,
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles drop events for both workflows and folders
|
||||
*
|
||||
* @param e - React drag event
|
||||
* @param targetFolderId - Target folder ID or null for root
|
||||
*/
|
||||
const handleFolderDrop = useCallback(
|
||||
async (e: React.DragEvent, targetFolderId: string | null) => {
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDropTargetId(null)
|
||||
|
||||
const indicator = dropIndicator
|
||||
setDropIndicator(null)
|
||||
setIsDragging(false)
|
||||
|
||||
if (!indicator) return
|
||||
|
||||
try {
|
||||
// Check if dropping workflows
|
||||
const workflowIdsData = e.dataTransfer.getData('workflow-ids')
|
||||
if (workflowIdsData) {
|
||||
const workflowIds = JSON.parse(workflowIdsData) as string[]
|
||||
await handleWorkflowDrop(workflowIds, targetFolderId)
|
||||
await handleWorkflowDrop(workflowIds, indicator)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if dropping a folder
|
||||
const folderIdData = e.dataTransfer.getData('folder-id')
|
||||
if (folderIdData && targetFolderId !== folderIdData) {
|
||||
await handleFolderMove(folderIdData, targetFolderId)
|
||||
if (folderIdData) {
|
||||
await handleFolderDrop(folderIdData, indicator)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle drop:', error)
|
||||
}
|
||||
},
|
||||
[handleWorkflowDrop, handleFolderMove]
|
||||
[dropIndicator, handleWorkflowDrop, handleFolderDrop]
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates drag event handlers for a specific folder section
|
||||
* These handlers are attached to the entire folder section container
|
||||
*
|
||||
* @param folderId - Folder ID to create handlers for
|
||||
* @returns Object containing drag event handlers
|
||||
*/
|
||||
const createFolderDragHandlers = useCallback(
|
||||
(folderId: string) => ({
|
||||
onDragEnter: (e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
},
|
||||
const createWorkflowDragHandlers = useCallback(
|
||||
(workflowId: string, folderId: string | null) => ({
|
||||
onDragOver: (e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
lastDragYRef.current = e.clientY
|
||||
setDropTargetId(folderId)
|
||||
setIsDragging(true)
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||
const currentTarget = e.currentTarget as HTMLElement
|
||||
// Only clear if we're leaving the folder section completely
|
||||
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
|
||||
setDropTargetId(null)
|
||||
initDragOver(e)
|
||||
const isSameFolder = draggedSourceFolderRef.current === folderId
|
||||
if (isSameFolder) {
|
||||
const position = calculateDropPosition(e, e.currentTarget)
|
||||
setNormalizedDropIndicator({ targetId: workflowId, position, folderId })
|
||||
} else {
|
||||
setNormalizedDropIndicator({
|
||||
targetId: folderId || 'root',
|
||||
position: 'inside',
|
||||
folderId: null,
|
||||
})
|
||||
}
|
||||
},
|
||||
onDrop: (e: React.DragEvent<HTMLElement>) => handleFolderDrop(e, folderId),
|
||||
onDrop: handleDrop,
|
||||
}),
|
||||
[handleFolderDrop]
|
||||
[initDragOver, calculateDropPosition, setNormalizedDropIndicator, handleDrop]
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates drag event handlers for items (workflows/folders) that belong to a parent folder
|
||||
* When dragging over an item, highlights the parent folder section
|
||||
*
|
||||
* @param parentFolderId - Parent folder ID or null for root
|
||||
* @returns Object containing drag event handlers
|
||||
*/
|
||||
const createItemDragHandlers = useCallback(
|
||||
(parentFolderId: string | null) => ({
|
||||
const createFolderDragHandlers = useCallback(
|
||||
(folderId: string, parentFolderId: string | null) => ({
|
||||
onDragOver: (e: React.DragEvent<HTMLElement>) => {
|
||||
initDragOver(e)
|
||||
if (draggedTypeRef.current === 'folder') {
|
||||
const isSameParent = draggedSourceFolderRef.current === parentFolderId
|
||||
if (isSameParent) {
|
||||
const position = calculateDropPosition(e, e.currentTarget)
|
||||
setNormalizedDropIndicator({ targetId: folderId, position, folderId: parentFolderId })
|
||||
} else {
|
||||
setNormalizedDropIndicator({
|
||||
targetId: folderId,
|
||||
position: 'inside',
|
||||
folderId: parentFolderId,
|
||||
})
|
||||
setHoverFolderId(folderId)
|
||||
}
|
||||
} else {
|
||||
// Workflow being dragged over a folder
|
||||
const isSameParent = draggedSourceFolderRef.current === parentFolderId
|
||||
if (isSameParent) {
|
||||
// Same level - use three zones: top=before, middle=inside, bottom=after
|
||||
const position = calculateFolderDropPosition(e, e.currentTarget)
|
||||
setNormalizedDropIndicator({ targetId: folderId, position, folderId: parentFolderId })
|
||||
if (position === 'inside') {
|
||||
setHoverFolderId(folderId)
|
||||
} else {
|
||||
setHoverFolderId(null)
|
||||
}
|
||||
} else {
|
||||
// Different container - drop into folder
|
||||
setNormalizedDropIndicator({
|
||||
targetId: folderId,
|
||||
position: 'inside',
|
||||
folderId: parentFolderId,
|
||||
})
|
||||
setHoverFolderId(folderId)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
|
||||
if (isLeavingElement(e)) setHoverFolderId(null)
|
||||
},
|
||||
onDrop: handleDrop,
|
||||
}),
|
||||
[
|
||||
initDragOver,
|
||||
calculateDropPosition,
|
||||
calculateFolderDropPosition,
|
||||
setNormalizedDropIndicator,
|
||||
isLeavingElement,
|
||||
handleDrop,
|
||||
]
|
||||
)
|
||||
|
||||
const createEmptyFolderDropZone = useCallback(
|
||||
(folderId: string) => ({
|
||||
onDragOver: (e: React.DragEvent<HTMLElement>) => {
|
||||
initDragOver(e)
|
||||
setNormalizedDropIndicator({ targetId: folderId, position: 'inside', folderId })
|
||||
},
|
||||
onDrop: handleDrop,
|
||||
}),
|
||||
[initDragOver, setNormalizedDropIndicator, handleDrop]
|
||||
)
|
||||
|
||||
const createFolderContentDropZone = useCallback(
|
||||
(folderId: string) => ({
|
||||
onDragOver: (e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
lastDragYRef.current = e.clientY
|
||||
setDropTargetId(parentFolderId || 'root')
|
||||
setIsDragging(true)
|
||||
if (e.target === e.currentTarget && draggedSourceFolderRef.current !== folderId) {
|
||||
setNormalizedDropIndicator({ targetId: folderId, position: 'inside', folderId: null })
|
||||
}
|
||||
},
|
||||
onDrop: handleDrop,
|
||||
}),
|
||||
[setNormalizedDropIndicator, handleDrop]
|
||||
)
|
||||
|
||||
const createRootDropZone = useCallback(
|
||||
() => ({
|
||||
onDragOver: (e: React.DragEvent<HTMLElement>) => {
|
||||
initDragOver(e, false)
|
||||
if (e.target === e.currentTarget) {
|
||||
setNormalizedDropIndicator({ targetId: 'root', position: 'inside', folderId: null })
|
||||
}
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
|
||||
if (isLeavingElement(e)) setNormalizedDropIndicator(null)
|
||||
},
|
||||
onDrop: handleDrop,
|
||||
}),
|
||||
[initDragOver, setNormalizedDropIndicator, isLeavingElement, handleDrop]
|
||||
)
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(type: 'workflow' | 'folder', sourceFolderId: string | null) => {
|
||||
draggedTypeRef.current = type
|
||||
draggedSourceFolderRef.current = sourceFolderId
|
||||
setIsDragging(true)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates drag event handlers for the root drop zone
|
||||
*
|
||||
* @returns Object containing drag event handlers for root
|
||||
*/
|
||||
const createRootDragHandlers = useCallback(
|
||||
() => ({
|
||||
onDragEnter: (e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
},
|
||||
onDragOver: (e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
lastDragYRef.current = e.clientY
|
||||
setDropTargetId('root')
|
||||
setIsDragging(true)
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||
const currentTarget = e.currentTarget as HTMLElement
|
||||
// Only clear if we're leaving the root completely
|
||||
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
|
||||
setDropTargetId(null)
|
||||
}
|
||||
},
|
||||
onDrop: (e: React.DragEvent<HTMLElement>) => handleFolderDrop(e, null),
|
||||
}),
|
||||
[handleFolderDrop]
|
||||
)
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setIsDragging(false)
|
||||
setDropIndicator(null)
|
||||
draggedTypeRef.current = null
|
||||
draggedSourceFolderRef.current = null
|
||||
setHoverFolderId(null)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Creates drag event handlers for folder header (the clickable part)
|
||||
* These handlers trigger folder expansion on hover during drag
|
||||
*
|
||||
* @param folderId - Folder ID to handle hover for
|
||||
* @returns Object containing drag event handlers for folder header
|
||||
*/
|
||||
const createFolderHeaderHoverHandlers = useCallback(
|
||||
(folderId: string) => ({
|
||||
onDragEnter: (e: React.DragEvent<HTMLElement>) => {
|
||||
if (isDragging) {
|
||||
setHoverFolderId(folderId)
|
||||
}
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||
const currentTarget = e.currentTarget as HTMLElement
|
||||
// Only clear if we're leaving the folder header completely
|
||||
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
|
||||
setHoverFolderId(null)
|
||||
}
|
||||
},
|
||||
}),
|
||||
[isDragging]
|
||||
)
|
||||
|
||||
/**
|
||||
* Set the scroll container ref for auto-scrolling
|
||||
*
|
||||
* @param element - Scrollable container element
|
||||
*/
|
||||
const setScrollContainer = useCallback((element: HTMLDivElement | null) => {
|
||||
scrollContainerRef.current = element
|
||||
}, [])
|
||||
|
||||
return {
|
||||
dropTargetId,
|
||||
dropIndicator,
|
||||
isDragging,
|
||||
setScrollContainer,
|
||||
createWorkflowDragHandlers,
|
||||
createFolderDragHandlers,
|
||||
createItemDragHandlers,
|
||||
createRootDragHandlers,
|
||||
createFolderHeaderHoverHandlers,
|
||||
createEmptyFolderDropZone,
|
||||
createFolderContentDropZone,
|
||||
createRootDropZone,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentId: folder.parentId,
|
||||
sortOrder: folder.sortOrder,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
extractWorkflowsFromFiles,
|
||||
extractWorkflowsFromZip,
|
||||
parseWorkflowJson,
|
||||
sanitizePathSegment,
|
||||
} from '@/lib/workflows/operations/import-export'
|
||||
import { folderKeys, useCreateFolder } from '@/hooks/queries/folders'
|
||||
import { useCreateWorkflow, workflowKeys } from '@/hooks/queries/workflows'
|
||||
@@ -40,7 +41,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
* Import a single workflow
|
||||
*/
|
||||
const importSingleWorkflow = useCallback(
|
||||
async (content: string, filename: string, folderId?: string) => {
|
||||
async (content: string, filename: string, folderId?: string, sortOrder?: number) => {
|
||||
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(content)
|
||||
|
||||
if (!workflowData || parseErrors.length > 0) {
|
||||
@@ -60,6 +61,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
description: workflowData.metadata?.description || 'Imported from JSON',
|
||||
workspaceId,
|
||||
folderId: folderId || undefined,
|
||||
sortOrder,
|
||||
})
|
||||
const newWorkflowId = result.id
|
||||
|
||||
@@ -140,6 +142,55 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
})
|
||||
const folderMap = new Map<string, string>()
|
||||
|
||||
if (metadata?.folders && metadata.folders.length > 0) {
|
||||
type ExportedFolder = {
|
||||
id: string
|
||||
name: string
|
||||
parentId: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
const foldersById = new Map<string, ExportedFolder>(
|
||||
metadata.folders.map((f) => [f.id, f])
|
||||
)
|
||||
const oldIdToNewId = new Map<string, string>()
|
||||
|
||||
const buildPath = (folderId: string): string => {
|
||||
const pathParts: string[] = []
|
||||
let currentId: string | null = folderId
|
||||
while (currentId && foldersById.has(currentId)) {
|
||||
const folder: ExportedFolder = foldersById.get(currentId)!
|
||||
pathParts.unshift(sanitizePathSegment(folder.name))
|
||||
currentId = folder.parentId
|
||||
}
|
||||
return pathParts.join('/')
|
||||
}
|
||||
|
||||
const createFolderRecursive = async (folder: ExportedFolder): Promise<string> => {
|
||||
if (oldIdToNewId.has(folder.id)) {
|
||||
return oldIdToNewId.get(folder.id)!
|
||||
}
|
||||
|
||||
let parentId = importFolder.id
|
||||
if (folder.parentId && foldersById.has(folder.parentId)) {
|
||||
parentId = await createFolderRecursive(foldersById.get(folder.parentId)!)
|
||||
}
|
||||
|
||||
const newFolder = await createFolderMutation.mutateAsync({
|
||||
name: folder.name,
|
||||
workspaceId,
|
||||
parentId,
|
||||
sortOrder: folder.sortOrder,
|
||||
})
|
||||
oldIdToNewId.set(folder.id, newFolder.id)
|
||||
folderMap.set(buildPath(folder.id), newFolder.id)
|
||||
return newFolder.id
|
||||
}
|
||||
|
||||
for (const folder of metadata.folders) {
|
||||
await createFolderRecursive(folder)
|
||||
}
|
||||
}
|
||||
|
||||
for (const workflow of extractedWorkflows) {
|
||||
try {
|
||||
let targetFolderId = importFolder.id
|
||||
@@ -147,15 +198,17 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
if (workflow.folderPath.length > 0) {
|
||||
const folderPathKey = workflow.folderPath.join('/')
|
||||
|
||||
if (!folderMap.has(folderPathKey)) {
|
||||
if (folderMap.has(folderPathKey)) {
|
||||
targetFolderId = folderMap.get(folderPathKey)!
|
||||
} else {
|
||||
let parentId = importFolder.id
|
||||
|
||||
for (let i = 0; i < workflow.folderPath.length; i++) {
|
||||
const pathSegment = workflow.folderPath.slice(0, i + 1).join('/')
|
||||
const folderNameForSegment = workflow.folderPath[i]
|
||||
|
||||
if (!folderMap.has(pathSegment)) {
|
||||
const subFolder = await createFolderMutation.mutateAsync({
|
||||
name: workflow.folderPath[i],
|
||||
name: folderNameForSegment,
|
||||
workspaceId,
|
||||
parentId,
|
||||
})
|
||||
@@ -165,15 +218,15 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
parentId = folderMap.get(pathSegment)!
|
||||
}
|
||||
}
|
||||
targetFolderId = folderMap.get(folderPathKey)!
|
||||
}
|
||||
|
||||
targetFolderId = folderMap.get(folderPathKey)!
|
||||
}
|
||||
|
||||
const workflowId = await importSingleWorkflow(
|
||||
workflow.content,
|
||||
workflow.name,
|
||||
targetFolderId
|
||||
targetFolderId,
|
||||
workflow.sortOrder
|
||||
)
|
||||
if (workflowId) importedWorkflowIds.push(workflowId)
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
extractWorkflowName,
|
||||
extractWorkflowsFromZip,
|
||||
parseWorkflowJson,
|
||||
sanitizePathSegment,
|
||||
} from '@/lib/workflows/operations/import-export'
|
||||
import { useCreateFolder } from '@/hooks/queries/folders'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
@@ -59,7 +60,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
const createResponse = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: workspaceName }),
|
||||
body: JSON.stringify({ name: workspaceName, skipDefaultWorkflow: true }),
|
||||
})
|
||||
|
||||
if (!createResponse.ok) {
|
||||
@@ -71,6 +72,55 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
|
||||
const folderMap = new Map<string, string>()
|
||||
|
||||
if (metadata?.folders && metadata.folders.length > 0) {
|
||||
type ExportedFolder = {
|
||||
id: string
|
||||
name: string
|
||||
parentId: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
const foldersById = new Map<string, ExportedFolder>(
|
||||
metadata.folders.map((f) => [f.id, f])
|
||||
)
|
||||
const oldIdToNewId = new Map<string, string>()
|
||||
|
||||
const buildPath = (folderId: string): string => {
|
||||
const pathParts: string[] = []
|
||||
let currentId: string | null = folderId
|
||||
while (currentId && foldersById.has(currentId)) {
|
||||
const folder: ExportedFolder = foldersById.get(currentId)!
|
||||
pathParts.unshift(sanitizePathSegment(folder.name))
|
||||
currentId = folder.parentId
|
||||
}
|
||||
return pathParts.join('/')
|
||||
}
|
||||
|
||||
const createFolderRecursive = async (folder: ExportedFolder): Promise<string> => {
|
||||
if (oldIdToNewId.has(folder.id)) {
|
||||
return oldIdToNewId.get(folder.id)!
|
||||
}
|
||||
|
||||
let parentId: string | undefined
|
||||
if (folder.parentId && foldersById.has(folder.parentId)) {
|
||||
parentId = await createFolderRecursive(foldersById.get(folder.parentId)!)
|
||||
}
|
||||
|
||||
const newFolder = await createFolderMutation.mutateAsync({
|
||||
name: folder.name,
|
||||
workspaceId: newWorkspace.id,
|
||||
parentId,
|
||||
sortOrder: folder.sortOrder,
|
||||
})
|
||||
oldIdToNewId.set(folder.id, newFolder.id)
|
||||
folderMap.set(buildPath(folder.id), newFolder.id)
|
||||
return newFolder.id
|
||||
}
|
||||
|
||||
for (const folder of metadata.folders) {
|
||||
await createFolderRecursive(folder)
|
||||
}
|
||||
}
|
||||
|
||||
for (const workflow of extractedWorkflows) {
|
||||
try {
|
||||
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(workflow.content)
|
||||
@@ -84,9 +134,10 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
if (workflow.folderPath.length > 0) {
|
||||
const folderPathKey = workflow.folderPath.join('/')
|
||||
|
||||
if (!folderMap.has(folderPathKey)) {
|
||||
let parentId: string | null = null
|
||||
|
||||
if (folderMap.has(folderPathKey)) {
|
||||
targetFolderId = folderMap.get(folderPathKey)!
|
||||
} else {
|
||||
let parentId: string | undefined
|
||||
for (let i = 0; i < workflow.folderPath.length; i++) {
|
||||
const pathSegment = workflow.folderPath.slice(0, i + 1).join('/')
|
||||
|
||||
@@ -94,7 +145,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
const subFolder = await createFolderMutation.mutateAsync({
|
||||
name: workflow.folderPath[i],
|
||||
workspaceId: newWorkspace.id,
|
||||
parentId: parentId || undefined,
|
||||
parentId,
|
||||
})
|
||||
folderMap.set(pathSegment, subFolder.id)
|
||||
parentId = subFolder.id
|
||||
@@ -102,9 +153,8 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
parentId = folderMap.get(pathSegment)!
|
||||
}
|
||||
}
|
||||
targetFolderId = folderMap.get(folderPathKey) || null
|
||||
}
|
||||
|
||||
targetFolderId = folderMap.get(folderPathKey) || null
|
||||
}
|
||||
|
||||
const workflowName = extractWorkflowName(workflow.content, workflow.name)
|
||||
|
||||
@@ -98,6 +98,23 @@ 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',
|
||||
@@ -208,6 +225,14 @@ 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',
|
||||
|
||||
@@ -26,6 +26,8 @@ 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' },
|
||||
@@ -316,6 +318,68 @@ 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',
|
||||
@@ -430,6 +494,8 @@ 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',
|
||||
@@ -448,6 +514,10 @@ 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':
|
||||
@@ -498,6 +568,9 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
includeDeleted,
|
||||
userLimit,
|
||||
userId,
|
||||
getMessageTimestamp,
|
||||
getThreadTimestamp,
|
||||
threadLimit,
|
||||
...rest
|
||||
} = params
|
||||
|
||||
@@ -574,6 +647,27 @@ 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
|
||||
@@ -679,6 +773,14 @@ 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)
|
||||
@@ -706,6 +808,24 @@ 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',
|
||||
|
||||
207
apps/sim/blocks/blocks/tinybird.ts
Normal file
207
apps/sim/blocks/blocks/tinybird.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
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)',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -121,6 +121,7 @@ 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'
|
||||
@@ -281,6 +282,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
tavily: TavilyBlock,
|
||||
telegram: TelegramBlock,
|
||||
thinking: ThinkingBlock,
|
||||
tinybird: TinybirdBlock,
|
||||
translate: TranslateBlock,
|
||||
trello: TrelloBlock,
|
||||
twilio_sms: TwilioSMSBlock,
|
||||
@@ -313,6 +315,26 @@ 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))
|
||||
}
|
||||
|
||||
@@ -1897,6 +1897,19 @@ 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'>
|
||||
|
||||
@@ -378,21 +378,10 @@ function buildManualTriggerOutput(
|
||||
}
|
||||
|
||||
function buildIntegrationTriggerOutput(
|
||||
finalInput: unknown,
|
||||
_finalInput: unknown,
|
||||
workflowInput: unknown
|
||||
): 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)
|
||||
return isPlainObject(workflowInput) ? (workflowInput as NormalizedBlockOutput) : {}
|
||||
}
|
||||
|
||||
function extractSubBlocks(block: SerializedBlock): Record<string, unknown> | undefined {
|
||||
|
||||
@@ -68,6 +68,7 @@ interface CreateFolderVariables {
|
||||
name: string
|
||||
parentId?: string
|
||||
color?: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
interface UpdateFolderVariables {
|
||||
@@ -160,18 +161,20 @@ export function useCreateFolder() {
|
||||
parentId: variables.parentId || null,
|
||||
color: variables.color || '#808080',
|
||||
isExpanded: false,
|
||||
sortOrder: getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId),
|
||||
sortOrder:
|
||||
variables.sortOrder ??
|
||||
getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, ...payload }: CreateFolderVariables) => {
|
||||
mutationFn: async ({ workspaceId, sortOrder, ...payload }: CreateFolderVariables) => {
|
||||
const response = await fetch('/api/folders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...payload, workspaceId }),
|
||||
body: JSON.stringify({ ...payload, workspaceId, sortOrder }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -285,9 +288,66 @@ export function useDuplicateFolderMutation() {
|
||||
},
|
||||
...handlers,
|
||||
onSettled: (_data, _error, variables) => {
|
||||
// Invalidate both folders and workflows (duplicated folder may contain workflows)
|
||||
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface ReorderFoldersVariables {
|
||||
workspaceId: string
|
||||
updates: Array<{
|
||||
id: string
|
||||
sortOrder: number
|
||||
parentId?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export function useReorderFolders() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: ReorderFoldersVariables): Promise<void> => {
|
||||
const response = await fetch('/api/folders/reorder', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(variables),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
throw new Error(error.error || 'Failed to reorder folders')
|
||||
}
|
||||
},
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({ queryKey: folderKeys.list(variables.workspaceId) })
|
||||
|
||||
const snapshot = { ...useFolderStore.getState().folders }
|
||||
|
||||
useFolderStore.setState((state) => {
|
||||
const updated = { ...state.folders }
|
||||
for (const update of variables.updates) {
|
||||
if (updated[update.id]) {
|
||||
updated[update.id] = {
|
||||
...updated[update.id],
|
||||
sortOrder: update.sortOrder,
|
||||
parentId:
|
||||
update.parentId !== undefined ? update.parentId : updated[update.id].parentId,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { folders: updated }
|
||||
})
|
||||
|
||||
return { snapshot }
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.snapshot) {
|
||||
useFolderStore.setState({ folders: context.snapshot })
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ function mapWorkflow(workflow: any): WorkflowMetadata {
|
||||
color: workflow.color,
|
||||
workspaceId: workflow.workspaceId,
|
||||
folderId: workflow.folderId,
|
||||
sortOrder: workflow.sortOrder ?? 0,
|
||||
createdAt: new Date(workflow.createdAt),
|
||||
lastModified: new Date(workflow.updatedAt || workflow.createdAt),
|
||||
}
|
||||
@@ -91,6 +92,7 @@ interface CreateWorkflowVariables {
|
||||
description?: string
|
||||
color?: string
|
||||
folderId?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
interface CreateWorkflowResult {
|
||||
@@ -100,6 +102,7 @@ interface CreateWorkflowResult {
|
||||
color: string
|
||||
workspaceId: string
|
||||
folderId?: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
interface DuplicateWorkflowVariables {
|
||||
@@ -118,6 +121,7 @@ interface DuplicateWorkflowResult {
|
||||
color: string
|
||||
workspaceId: string
|
||||
folderId?: string | null
|
||||
sortOrder: number
|
||||
blocksCount: number
|
||||
edgesCount: number
|
||||
subflowsCount: number
|
||||
@@ -161,6 +165,7 @@ function createWorkflowMutationHandlers<TVariables extends { workspaceId: string
|
||||
color: data.color,
|
||||
workspaceId: data.workspaceId,
|
||||
folderId: data.folderId,
|
||||
sortOrder: 'sortOrder' in data ? data.sortOrder : 0,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
@@ -179,21 +184,36 @@ export function useCreateWorkflow() {
|
||||
const handlers = createWorkflowMutationHandlers<CreateWorkflowVariables>(
|
||||
queryClient,
|
||||
'CreateWorkflow',
|
||||
(variables, tempId) => ({
|
||||
id: tempId,
|
||||
name: variables.name || generateCreativeWorkflowName(),
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: variables.description || 'New workflow',
|
||||
color: variables.color || getNextWorkflowColor(),
|
||||
workspaceId: variables.workspaceId,
|
||||
folderId: variables.folderId || null,
|
||||
})
|
||||
(variables, tempId) => {
|
||||
let sortOrder: number
|
||||
if (variables.sortOrder !== undefined) {
|
||||
sortOrder = variables.sortOrder
|
||||
} else {
|
||||
const currentWorkflows = useWorkflowRegistry.getState().workflows
|
||||
const targetFolderId = variables.folderId || null
|
||||
const workflowsInFolder = Object.values(currentWorkflows).filter(
|
||||
(w) => w.folderId === targetFolderId
|
||||
)
|
||||
sortOrder = workflowsInFolder.reduce((max, w) => Math.max(max, w.sortOrder ?? 0), -1) + 1
|
||||
}
|
||||
|
||||
return {
|
||||
id: tempId,
|
||||
name: variables.name || generateCreativeWorkflowName(),
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: variables.description || 'New workflow',
|
||||
color: variables.color || getNextWorkflowColor(),
|
||||
workspaceId: variables.workspaceId,
|
||||
folderId: variables.folderId || null,
|
||||
sortOrder,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: CreateWorkflowVariables): Promise<CreateWorkflowResult> => {
|
||||
const { workspaceId, name, description, color, folderId } = variables
|
||||
const { workspaceId, name, description, color, folderId, sortOrder } = variables
|
||||
|
||||
logger.info(`Creating new workflow in workspace: ${workspaceId}`)
|
||||
|
||||
@@ -206,6 +226,7 @@ export function useCreateWorkflow() {
|
||||
color: color || getNextWorkflowColor(),
|
||||
workspaceId,
|
||||
folderId: folderId || null,
|
||||
sortOrder,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -243,13 +264,13 @@ export function useCreateWorkflow() {
|
||||
color: createdWorkflow.color,
|
||||
workspaceId,
|
||||
folderId: createdWorkflow.folderId,
|
||||
sortOrder: createdWorkflow.sortOrder ?? 0,
|
||||
}
|
||||
},
|
||||
...handlers,
|
||||
onSuccess: (data, variables, context) => {
|
||||
handlers.onSuccess(data, variables, context)
|
||||
|
||||
// Initialize subblock values for new workflow
|
||||
const { subBlockValues } = buildDefaultWorkflowArtifacts()
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
@@ -267,16 +288,26 @@ export function useDuplicateWorkflowMutation() {
|
||||
const handlers = createWorkflowMutationHandlers<DuplicateWorkflowVariables>(
|
||||
queryClient,
|
||||
'DuplicateWorkflow',
|
||||
(variables, tempId) => ({
|
||||
id: tempId,
|
||||
name: variables.name,
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: variables.description,
|
||||
color: variables.color,
|
||||
workspaceId: variables.workspaceId,
|
||||
folderId: variables.folderId || null,
|
||||
})
|
||||
(variables, tempId) => {
|
||||
const currentWorkflows = useWorkflowRegistry.getState().workflows
|
||||
const targetFolderId = variables.folderId || null
|
||||
const workflowsInFolder = Object.values(currentWorkflows).filter(
|
||||
(w) => w.folderId === targetFolderId
|
||||
)
|
||||
const maxSortOrder = workflowsInFolder.reduce((max, w) => Math.max(max, w.sortOrder ?? 0), -1)
|
||||
|
||||
return {
|
||||
id: tempId,
|
||||
name: variables.name,
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: variables.description,
|
||||
color: variables.color,
|
||||
workspaceId: variables.workspaceId,
|
||||
folderId: targetFolderId,
|
||||
sortOrder: maxSortOrder + 1,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
@@ -317,6 +348,7 @@ export function useDuplicateWorkflowMutation() {
|
||||
color: duplicatedWorkflow.color || color,
|
||||
workspaceId,
|
||||
folderId: duplicatedWorkflow.folderId ?? folderId,
|
||||
sortOrder: duplicatedWorkflow.sortOrder ?? 0,
|
||||
blocksCount: duplicatedWorkflow.blocksCount || 0,
|
||||
edgesCount: duplicatedWorkflow.edgesCount || 0,
|
||||
subflowsCount: duplicatedWorkflow.subflowsCount || 0,
|
||||
@@ -398,3 +430,61 @@ export function useRevertToVersion() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface ReorderWorkflowsVariables {
|
||||
workspaceId: string
|
||||
updates: Array<{
|
||||
id: string
|
||||
sortOrder: number
|
||||
folderId?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export function useReorderWorkflows() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: ReorderWorkflowsVariables): Promise<void> => {
|
||||
const response = await fetch('/api/workflows/reorder', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(variables),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
throw new Error(error.error || 'Failed to reorder workflows')
|
||||
}
|
||||
},
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({ queryKey: workflowKeys.list(variables.workspaceId) })
|
||||
|
||||
const snapshot = { ...useWorkflowRegistry.getState().workflows }
|
||||
|
||||
useWorkflowRegistry.setState((state) => {
|
||||
const updated = { ...state.workflows }
|
||||
for (const update of variables.updates) {
|
||||
if (updated[update.id]) {
|
||||
updated[update.id] = {
|
||||
...updated[update.id],
|
||||
sortOrder: update.sortOrder,
|
||||
folderId:
|
||||
update.folderId !== undefined ? update.folderId : updated[update.id].folderId,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { workflows: updated }
|
||||
})
|
||||
|
||||
return { snapshot }
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.snapshot) {
|
||||
useWorkflowRegistry.setState({ workflows: context.snapshot })
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 { mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
|
||||
import { filterNewEdges, 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,7 +242,10 @@ export function useCollaborativeWorkflow() {
|
||||
case EDGES_OPERATIONS.BATCH_ADD_EDGES: {
|
||||
const { edges } = payload
|
||||
if (Array.isArray(edges) && edges.length > 0) {
|
||||
workflowStore.batchAddEdges(edges)
|
||||
const newEdges = filterNewEdges(edges, workflowStore.edges)
|
||||
if (newEdges.length > 0) {
|
||||
workflowStore.batchAddEdges(newEdges)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -976,6 +979,9 @@ 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({
|
||||
@@ -983,16 +989,16 @@ export function useCollaborativeWorkflow() {
|
||||
operation: {
|
||||
operation: EDGES_OPERATIONS.BATCH_ADD_EDGES,
|
||||
target: OPERATION_TARGETS.EDGES,
|
||||
payload: { edges },
|
||||
payload: { edges: newEdges },
|
||||
},
|
||||
workflowId: activeWorkflowId || '',
|
||||
userId: session?.user?.id || 'unknown',
|
||||
})
|
||||
|
||||
workflowStore.batchAddEdges(edges)
|
||||
workflowStore.batchAddEdges(newEdges)
|
||||
|
||||
if (!options?.skipUndoRedo) {
|
||||
edges.forEach((edge) => undoRedo.recordAddEdge(edge.id))
|
||||
newEdges.forEach((edge) => undoRedo.recordAddEdge(edge.id))
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
@@ -36,9 +36,10 @@ class ApiKeyInterceptor implements CallInterceptor {
|
||||
/**
|
||||
* Create an A2A client from an agent URL with optional API key authentication
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export async function createA2AClient(agentUrl: string, apiKey?: string): Promise<Client> {
|
||||
const factoryOptions = apiKey
|
||||
@@ -49,6 +50,18 @@ 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, '')
|
||||
}
|
||||
|
||||
|
||||
@@ -656,7 +656,7 @@ export const auth = betterAuth({
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: profile.id.toString(),
|
||||
id: `${profile.id.toString()}-${crypto.randomUUID()}`,
|
||||
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,
|
||||
id: `${uniqueId}-${crypto.randomUUID()}`,
|
||||
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(),
|
||||
id: `${user.id.toString()}-${crypto.randomUUID()}`,
|
||||
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(),
|
||||
id: `${data.user_id || data.hub_id.toString()}-${crypto.randomUUID()}`,
|
||||
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,
|
||||
id: `${data.user_id || data.sub}-${crypto.randomUUID()}`,
|
||||
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,
|
||||
id: `${profile.data.id}-${crypto.randomUUID()}`,
|
||||
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,
|
||||
id: `${profile.account_id}-${crypto.randomUUID()}`,
|
||||
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,
|
||||
id: `${profile.account_id}-${crypto.randomUUID()}`,
|
||||
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,
|
||||
id: `${data.id}-${crypto.randomUUID()}`,
|
||||
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,
|
||||
id: `${profile.bot?.owner?.user?.id || profile.id}-${crypto.randomUUID()}`,
|
||||
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,
|
||||
id: `${data.id}-${crypto.randomUUID()}`,
|
||||
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,
|
||||
id: `${viewer.id}-${crypto.randomUUID()}`,
|
||||
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,
|
||||
id: `${data.account_id}-${crypto.randomUUID()}`,
|
||||
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,
|
||||
id: `${profile.gid}-${crypto.randomUUID()}`,
|
||||
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,
|
||||
id: `${uniqueId}-${crypto.randomUUID()}`,
|
||||
name: teamName,
|
||||
email: `${teamId}-${userId}@slack.bot`,
|
||||
emailVerified: false,
|
||||
@@ -1884,7 +1884,7 @@ export const auth = betterAuth({
|
||||
const uniqueId = `webflow-${userId}`
|
||||
|
||||
return {
|
||||
id: uniqueId,
|
||||
id: `${uniqueId}-${crypto.randomUUID()}`,
|
||||
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,
|
||||
id: `${profile.sub}-${crypto.randomUUID()}`,
|
||||
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,
|
||||
id: `${profile.id}-${crypto.randomUUID()}`,
|
||||
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,
|
||||
id: `${profile.id}-${crypto.randomUUID()}`,
|
||||
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(),
|
||||
id: `${profile.ID?.toString() || profile.id?.toString()}-${crypto.randomUUID()}`,
|
||||
name: profile.display_name || profile.username || 'WordPress User',
|
||||
email: profile.email || `${profile.username}@wordpress.com`,
|
||||
emailVerified: profile.email_verified || false,
|
||||
|
||||
@@ -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.001
|
||||
export const BASE_EXECUTION_CHARGE = 0.005
|
||||
|
||||
/**
|
||||
* Fixed cost for search tool invocation (in dollars)
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
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' },
|
||||
@@ -20,30 +19,22 @@ 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,
|
||||
@@ -54,11 +45,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -79,7 +68,6 @@ 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}`
|
||||
@@ -114,6 +102,40 @@ 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
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
|
||||
// Mock the billing constants
|
||||
vi.mock('@/lib/billing/constants', () => ({
|
||||
BASE_EXECUTION_CHARGE: 0.001,
|
||||
BASE_EXECUTION_CHARGE: 0.005,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
@@ -148,7 +148,7 @@ describe('createEnvironmentObject', () => {
|
||||
})
|
||||
|
||||
describe('calculateCostSummary', () => {
|
||||
const BASE_EXECUTION_CHARGE = 0.001
|
||||
const BASE_EXECUTION_CHARGE = 0.005
|
||||
|
||||
test('should return base execution charge for empty trace spans', () => {
|
||||
const result = calculateCostSummary([])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { getLatestBlock } from '@/blocks/registry'
|
||||
import { getAllTriggers } from '@/triggers'
|
||||
|
||||
export interface TriggerOption {
|
||||
@@ -49,22 +49,13 @@ export function getTriggerOptions(): TriggerOption[] {
|
||||
continue
|
||||
}
|
||||
|
||||
const block = getBlock(provider)
|
||||
const block = getLatestBlock(provider)
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
providerMap.set(provider, {
|
||||
value: provider,
|
||||
label: block?.name || formatProviderName(provider),
|
||||
color: block?.bgColor || '#6b7280',
|
||||
})
|
||||
}
|
||||
|
||||
const integrationOptions = Array.from(providerMap.values()).sort((a, b) =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2290,7 +2290,7 @@ describe('hasWorkflowChanged', () => {
|
||||
block1: createBlock('block1', {
|
||||
type: 'starter',
|
||||
subBlocks: {
|
||||
triggerConfig: { value: { event: 'push' } },
|
||||
model: { value: 'gpt-4' },
|
||||
webhookId: { value: null },
|
||||
},
|
||||
}),
|
||||
@@ -2302,7 +2302,7 @@ describe('hasWorkflowChanged', () => {
|
||||
block1: createBlock('block1', {
|
||||
type: 'starter',
|
||||
subBlocks: {
|
||||
triggerConfig: { value: { event: 'push' } },
|
||||
model: { value: 'gpt-4' },
|
||||
webhookId: { value: 'wh_123456' },
|
||||
},
|
||||
}),
|
||||
@@ -2318,7 +2318,7 @@ describe('hasWorkflowChanged', () => {
|
||||
block1: createBlock('block1', {
|
||||
type: 'starter',
|
||||
subBlocks: {
|
||||
triggerConfig: { value: { event: 'push' } },
|
||||
model: { value: 'gpt-4' },
|
||||
triggerPath: { value: '' },
|
||||
},
|
||||
}),
|
||||
@@ -2330,7 +2330,7 @@ describe('hasWorkflowChanged', () => {
|
||||
block1: createBlock('block1', {
|
||||
type: 'starter',
|
||||
subBlocks: {
|
||||
triggerConfig: { value: { event: 'push' } },
|
||||
model: { value: 'gpt-4' },
|
||||
triggerPath: { value: '/api/webhooks/abc123' },
|
||||
},
|
||||
}),
|
||||
@@ -2346,7 +2346,7 @@ describe('hasWorkflowChanged', () => {
|
||||
block1: createBlock('block1', {
|
||||
type: 'starter',
|
||||
subBlocks: {
|
||||
triggerConfig: { value: { event: 'push' } },
|
||||
model: { value: 'gpt-4' },
|
||||
webhookId: { value: null },
|
||||
triggerPath: { value: '' },
|
||||
},
|
||||
@@ -2359,7 +2359,7 @@ describe('hasWorkflowChanged', () => {
|
||||
block1: createBlock('block1', {
|
||||
type: 'starter',
|
||||
subBlocks: {
|
||||
triggerConfig: { value: { event: 'push' } },
|
||||
model: { value: 'gpt-4' },
|
||||
webhookId: { value: 'wh_123456' },
|
||||
triggerPath: { value: '/api/webhooks/abc123' },
|
||||
},
|
||||
@@ -2371,14 +2371,18 @@ describe('hasWorkflowChanged', () => {
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'should detect change when triggerConfig differs but runtime metadata also differs',
|
||||
'should detect change when actual config 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: {
|
||||
triggerConfig: { value: { event: 'push' } },
|
||||
model: { value: 'gpt-4' },
|
||||
webhookId: { value: null },
|
||||
},
|
||||
}),
|
||||
@@ -2390,7 +2394,7 @@ describe('hasWorkflowChanged', () => {
|
||||
block1: createBlock('block1', {
|
||||
type: 'starter',
|
||||
subBlocks: {
|
||||
triggerConfig: { value: { event: 'pull_request' } },
|
||||
model: { value: 'gpt-4o' },
|
||||
webhookId: { value: 'wh_123456' },
|
||||
},
|
||||
}),
|
||||
@@ -2402,8 +2406,12 @@ describe('hasWorkflowChanged', () => {
|
||||
)
|
||||
|
||||
it.concurrent(
|
||||
'should not detect change when runtime metadata is added to current state',
|
||||
'should not detect change when triggerConfig differs (individual fields compared separately)',
|
||||
() => {
|
||||
// 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', {
|
||||
@@ -2420,7 +2428,36 @@ describe('hasWorkflowChanged', () => {
|
||||
block1: createBlock('block1', {
|
||||
type: 'starter',
|
||||
subBlocks: {
|
||||
triggerConfig: { value: { event: 'push' } },
|
||||
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' },
|
||||
webhookId: { value: 'wh_123456' },
|
||||
triggerPath: { value: '/api/webhooks/abc123' },
|
||||
},
|
||||
@@ -2440,7 +2477,7 @@ describe('hasWorkflowChanged', () => {
|
||||
block1: createBlock('block1', {
|
||||
type: 'starter',
|
||||
subBlocks: {
|
||||
triggerConfig: { value: { event: 'push' } },
|
||||
model: { value: 'gpt-4' },
|
||||
webhookId: { value: 'wh_old123' },
|
||||
triggerPath: { value: '/api/webhooks/old' },
|
||||
},
|
||||
@@ -2453,7 +2490,7 @@ describe('hasWorkflowChanged', () => {
|
||||
block1: createBlock('block1', {
|
||||
type: 'starter',
|
||||
subBlocks: {
|
||||
triggerConfig: { value: { event: 'push' } },
|
||||
model: { value: 'gpt-4' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface WorkflowExportData {
|
||||
description?: string
|
||||
color?: string
|
||||
folderId?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
state: WorkflowState
|
||||
variables?: Record<string, Variable>
|
||||
@@ -25,6 +26,7 @@ export interface FolderExportData {
|
||||
id: string
|
||||
name: string
|
||||
parentId: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface WorkspaceExportStructure {
|
||||
@@ -186,7 +188,12 @@ export async function exportWorkspaceToZip(
|
||||
name: workspaceName,
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
folders: folders.map((f) => ({ id: f.id, name: f.name, parentId: f.parentId })),
|
||||
folders: folders.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
parentId: f.parentId,
|
||||
sortOrder: f.sortOrder,
|
||||
})),
|
||||
}
|
||||
|
||||
zip.file('_workspace.json', JSON.stringify(metadata, null, 2))
|
||||
@@ -199,6 +206,7 @@ export async function exportWorkspaceToZip(
|
||||
name: workflow.workflow.name,
|
||||
description: workflow.workflow.description,
|
||||
color: workflow.workflow.color,
|
||||
sortOrder: workflow.workflow.sortOrder,
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
variables: workflow.variables,
|
||||
@@ -279,11 +287,27 @@ export interface ImportedWorkflow {
|
||||
content: string
|
||||
name: string
|
||||
folderPath: string[]
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface WorkspaceImportMetadata {
|
||||
workspaceName: string
|
||||
exportedAt?: string
|
||||
folders?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
parentId: string | null
|
||||
sortOrder?: number
|
||||
}>
|
||||
}
|
||||
|
||||
function extractSortOrder(content: string): number | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
return parsed.state?.metadata?.sortOrder ?? parsed.metadata?.sortOrder
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractWorkflowsFromZip(
|
||||
@@ -303,6 +327,7 @@ export async function extractWorkflowsFromZip(
|
||||
metadata = {
|
||||
workspaceName: parsed.workspace?.name || 'Imported Workspace',
|
||||
exportedAt: parsed.workspace?.exportedAt,
|
||||
folders: parsed.folders,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse workspace metadata:', error)
|
||||
@@ -321,6 +346,7 @@ export async function extractWorkflowsFromZip(
|
||||
content,
|
||||
name: filename,
|
||||
folderPath: pathParts,
|
||||
sortOrder: extractSortOrder(content),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to extract ${path}:`, error)
|
||||
@@ -338,10 +364,12 @@ export async function extractWorkflowsFromFiles(files: File[]): Promise<Imported
|
||||
|
||||
try {
|
||||
const content = await file.text()
|
||||
|
||||
workflows.push({
|
||||
content,
|
||||
name: file.name,
|
||||
folderPath: [],
|
||||
sortOrder: extractSortOrder(content),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read ${file.name}:`, error)
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface ExportWorkflowState {
|
||||
metadata?: {
|
||||
name?: string
|
||||
description?: string
|
||||
color?: string
|
||||
sortOrder?: number
|
||||
exportedAt?: string
|
||||
}
|
||||
variables?: Array<{
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
"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",
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { indexedDBStorage } from './storage'
|
||||
export { useTerminalConsoleStore } from './store'
|
||||
export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './types'
|
||||
|
||||
81
apps/sim/stores/terminal/console/storage.ts
Normal file
81
apps/sim/stores/terminal/console/storage.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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 })
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
import { createJSONStorage, 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')
|
||||
|
||||
/**
|
||||
* Updates a NormalizedBlockOutput with new content
|
||||
* Maximum number of console entries to keep per workflow.
|
||||
* Keeps the stored data size reasonable and improves performance.
|
||||
*/
|
||||
const MAX_ENTRIES_PER_WORKFLOW = 1000
|
||||
|
||||
const updateBlockOutput = (
|
||||
existingOutput: NormalizedBlockOutput | undefined,
|
||||
contentUpdate: string
|
||||
@@ -23,9 +27,6 @@ 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
|
||||
@@ -44,9 +45,6 @@ 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
|
||||
@@ -69,6 +67,9 @@ 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) => {
|
||||
@@ -94,7 +95,59 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
return { entries: [newEntry, ...state.entries] }
|
||||
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 }
|
||||
})
|
||||
|
||||
const newEntry = get().entries[0]
|
||||
@@ -130,10 +183,6 @@ 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),
|
||||
@@ -148,9 +197,6 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a value for CSV export
|
||||
*/
|
||||
const formatCSVValue = (value: any): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
@@ -297,7 +343,35 @@ 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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
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
|
||||
@@ -25,9 +22,6 @@ export interface ConsoleEntry {
|
||||
iterationType?: SubflowType
|
||||
}
|
||||
|
||||
/**
|
||||
* Console update payload for partial updates
|
||||
*/
|
||||
export interface ConsoleUpdate {
|
||||
content?: string
|
||||
output?: Partial<NormalizedBlockOutput>
|
||||
@@ -40,9 +34,6 @@ export interface ConsoleUpdate {
|
||||
input?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Console store state and actions
|
||||
*/
|
||||
export interface ConsoleStore {
|
||||
entries: ConsoleEntry[]
|
||||
isOpen: boolean
|
||||
@@ -52,4 +43,6 @@ export interface ConsoleStore {
|
||||
getWorkflowEntries: (workflowId: string) => ConsoleEntry[]
|
||||
toggleConsole: () => void
|
||||
updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => void
|
||||
_hasHydrated: boolean
|
||||
setHasHydrated: (hasHydrated: boolean) => void
|
||||
}
|
||||
|
||||
@@ -476,7 +476,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
// Use the server-generated ID
|
||||
const id = duplicatedWorkflow.id
|
||||
|
||||
// Generate new workflow metadata using the server-generated ID
|
||||
const newWorkflow: WorkflowMetadata = {
|
||||
id,
|
||||
name: `${sourceWorkflow.name} (Copy)`,
|
||||
@@ -484,8 +483,9 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
createdAt: new Date(),
|
||||
description: sourceWorkflow.description,
|
||||
color: getNextWorkflowColor(),
|
||||
workspaceId, // Include the workspaceId in the new workflow
|
||||
folderId: sourceWorkflow.folderId, // Include the folderId from source workflow
|
||||
workspaceId,
|
||||
folderId: sourceWorkflow.folderId,
|
||||
sortOrder: duplicatedWorkflow.sortOrder ?? 0,
|
||||
}
|
||||
|
||||
// Get the current workflow state to copy from
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface WorkflowMetadata {
|
||||
color: string
|
||||
workspaceId?: string
|
||||
folderId?: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export type HydrationPhase =
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
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
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
|
||||
@@ -297,7 +297,7 @@ describe('workflow store', () => {
|
||||
expectEdgeConnects(edges, 'block-1', 'block-2')
|
||||
})
|
||||
|
||||
it('should not add duplicate edges', () => {
|
||||
it('should not add duplicate connections', () => {
|
||||
const { addBlock, batchAddEdges } = useWorkflowStore.getState()
|
||||
|
||||
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
|
||||
@@ -309,17 +309,6 @@ 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', () => {
|
||||
|
||||
@@ -9,7 +9,12 @@ 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 { getUniqueBlockName, mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
|
||||
import {
|
||||
filterNewEdges,
|
||||
getUniqueBlockName,
|
||||
mergeSubblockState,
|
||||
normalizeName,
|
||||
} from '@/stores/workflows/utils'
|
||||
import type {
|
||||
Position,
|
||||
SubBlockState,
|
||||
@@ -496,25 +501,11 @@ 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 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
|
||||
for (const edge of filtered) {
|
||||
if (wouldCreateCycle([...newEdges], edge.source, edge.target)) continue
|
||||
|
||||
newEdges.push({
|
||||
id: edge.id || crypto.randomUUID(),
|
||||
source: edge.source,
|
||||
@@ -524,8 +515,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
type: edge.type || 'default',
|
||||
data: edge.data || {},
|
||||
})
|
||||
existingEdgeIds.add(edge.id)
|
||||
existingConnections.add(connectionKey)
|
||||
}
|
||||
|
||||
const blocks = get().blocks
|
||||
|
||||
@@ -30,11 +30,14 @@ export const a2aCancelTaskTool: ToolConfig<A2ACancelTaskParams, A2ACancelTaskRes
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params: A2ACancelTaskParams) => ({
|
||||
agentUrl: params.agentUrl,
|
||||
taskId: params.taskId,
|
||||
apiKey: params.apiKey,
|
||||
}),
|
||||
body: (params: A2ACancelTaskParams) => {
|
||||
const body: Record<string, string> = {
|
||||
agentUrl: params.agentUrl,
|
||||
taskId: params.taskId,
|
||||
}
|
||||
if (params.apiKey) body.apiKey = params.apiKey
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
|
||||
@@ -38,12 +38,16 @@ export const a2aDeletePushNotificationTool: ToolConfig<
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
agentUrl: params.agentUrl,
|
||||
taskId: params.taskId,
|
||||
pushNotificationConfigId: params.pushNotificationConfigId,
|
||||
apiKey: params.apiKey,
|
||||
}),
|
||||
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
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
|
||||
@@ -25,10 +25,13 @@ export const a2aGetAgentCardTool: ToolConfig<A2AGetAgentCardParams, A2AGetAgentC
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
agentUrl: params.agentUrl,
|
||||
apiKey: params.apiKey,
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, string> = {
|
||||
agentUrl: params.agentUrl,
|
||||
}
|
||||
if (params.apiKey) body.apiKey = params.apiKey
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
|
||||
@@ -33,11 +33,14 @@ export const a2aGetPushNotificationTool: ToolConfig<
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
agentUrl: params.agentUrl,
|
||||
taskId: params.taskId,
|
||||
apiKey: params.apiKey,
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, string> = {
|
||||
agentUrl: params.agentUrl,
|
||||
taskId: params.taskId,
|
||||
}
|
||||
if (params.apiKey) body.apiKey = params.apiKey
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
|
||||
@@ -34,12 +34,15 @@ export const a2aGetTaskTool: ToolConfig<A2AGetTaskParams, A2AGetTaskResponse> =
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params: A2AGetTaskParams) => ({
|
||||
agentUrl: params.agentUrl,
|
||||
taskId: params.taskId,
|
||||
apiKey: params.apiKey,
|
||||
historyLength: params.historyLength,
|
||||
}),
|
||||
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
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
|
||||
@@ -5,7 +5,6 @@ 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 {
|
||||
@@ -16,6 +15,5 @@ export {
|
||||
a2aGetTaskTool,
|
||||
a2aResubscribeTool,
|
||||
a2aSendMessageTool,
|
||||
a2aSendMessageStreamTool,
|
||||
a2aSetPushNotificationTool,
|
||||
}
|
||||
|
||||
@@ -30,11 +30,14 @@ export const a2aResubscribeTool: ToolConfig<A2AResubscribeParams, A2AResubscribe
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params: A2AResubscribeParams) => ({
|
||||
agentUrl: params.agentUrl,
|
||||
taskId: params.taskId,
|
||||
apiKey: params.apiKey,
|
||||
}),
|
||||
body: (params: A2AResubscribeParams) => {
|
||||
const body: Record<string, string> = {
|
||||
agentUrl: params.agentUrl,
|
||||
taskId: params.taskId,
|
||||
}
|
||||
if (params.apiKey) body.apiKey = params.apiKey
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
|
||||
@@ -26,6 +26,14 @@ 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',
|
||||
@@ -35,7 +43,21 @@ export const a2aSendMessageTool: ToolConfig<A2ASendMessageParams, A2ASendMessage
|
||||
request: {
|
||||
url: '/api/tools/a2a/send-message',
|
||||
method: 'POST',
|
||||
headers: () => ({}),
|
||||
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
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -42,13 +42,16 @@ export const a2aSetPushNotificationTool: ToolConfig<
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params: A2ASetPushNotificationParams) => ({
|
||||
agentUrl: params.agentUrl,
|
||||
taskId: params.taskId,
|
||||
webhookUrl: params.webhookUrl,
|
||||
token: params.token,
|
||||
apiKey: params.apiKey,
|
||||
}),
|
||||
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
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
|
||||
@@ -25,11 +25,20 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
a2aGetPushNotificationTool,
|
||||
a2aGetTaskTool,
|
||||
a2aResubscribeTool,
|
||||
a2aSendMessageStreamTool,
|
||||
a2aSendMessageTool,
|
||||
a2aSetPushNotificationTool,
|
||||
} from '@/tools/a2a'
|
||||
@@ -1180,6 +1179,8 @@ import {
|
||||
slackCanvasTool,
|
||||
slackDeleteMessageTool,
|
||||
slackDownloadTool,
|
||||
slackGetMessageTool,
|
||||
slackGetThreadTool,
|
||||
slackGetUserTool,
|
||||
slackListChannelsTool,
|
||||
slackListMembersTool,
|
||||
@@ -1380,6 +1381,7 @@ import {
|
||||
telegramSendVideoTool,
|
||||
} from '@/tools/telegram'
|
||||
import { thinkingTool } from '@/tools/thinking'
|
||||
import { tinybirdEventsTool, tinybirdQueryTool } from '@/tools/tinybird'
|
||||
import {
|
||||
trelloAddCommentTool,
|
||||
trelloCreateCardTool,
|
||||
@@ -1541,7 +1543,6 @@ 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,
|
||||
@@ -1731,6 +1732,8 @@ 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,
|
||||
@@ -2235,6 +2238,8 @@ 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,
|
||||
|
||||
213
apps/sim/tools/slack/get_message.ts
Normal file
213
apps/sim/tools/slack/get_message.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
224
apps/sim/tools/slack/get_thread.ts
Normal file
224
apps/sim/tools/slack/get_thread.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import type { SlackGetThreadParams, SlackGetThreadResponse } from '@/tools/slack/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const slackGetThreadTool: ToolConfig<SlackGetThreadParams, SlackGetThreadResponse> = {
|
||||
id: 'slack_get_thread',
|
||||
name: 'Slack Get Thread',
|
||||
description:
|
||||
'Retrieve an entire thread including the parent message and all replies. Useful for getting full conversation context.',
|
||||
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)',
|
||||
},
|
||||
threadTs: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Thread timestamp (thread_ts) to retrieve (e.g., 1405894322.002768)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of messages to return (default: 100, max: 200)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: SlackGetThreadParams) => {
|
||||
const url = new URL('https://slack.com/api/conversations.replies')
|
||||
url.searchParams.append('channel', params.channel?.trim() ?? '')
|
||||
url.searchParams.append('ts', params.threadTs?.trim() ?? '')
|
||||
url.searchParams.append('inclusive', 'true')
|
||||
const limit = params.limit ? Math.min(Number(params.limit), 200) : 100
|
||||
url.searchParams.append('limit', String(limit))
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: SlackGetThreadParams) => ({
|
||||
'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.')
|
||||
}
|
||||
if (data.error === 'thread_not_found') {
|
||||
throw new Error('Thread not found. Please check the thread timestamp.')
|
||||
}
|
||||
throw new Error(data.error || 'Failed to get thread from Slack')
|
||||
}
|
||||
|
||||
const rawMessages = data.messages || []
|
||||
if (rawMessages.length === 0) {
|
||||
throw new Error('Thread not found')
|
||||
}
|
||||
|
||||
const messages = rawMessages.map((msg: any) => ({
|
||||
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,
|
||||
}))
|
||||
|
||||
// First message is always the parent
|
||||
const parentMessage = messages[0]
|
||||
// Remaining messages are replies
|
||||
const replies = messages.slice(1)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
parentMessage,
|
||||
replies,
|
||||
messages,
|
||||
replyCount: replies.length,
|
||||
hasMore: data.has_more ?? false,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
parentMessage: {
|
||||
type: 'object',
|
||||
description: 'The thread parent message',
|
||||
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 },
|
||||
reply_count: { type: 'number', description: 'Total number of thread replies' },
|
||||
reply_users_count: { type: 'number', description: 'Number of users who replied' },
|
||||
latest_reply: { type: 'string', description: 'Timestamp of latest reply' },
|
||||
reactions: {
|
||||
type: 'array',
|
||||
description: 'Array of reactions on the parent 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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
files: {
|
||||
type: 'array',
|
||||
description: 'Files attached to the parent 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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
replies: {
|
||||
type: 'array',
|
||||
description: 'Array of reply messages in the thread (excluding the parent)',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ts: { type: 'string', description: 'Message timestamp' },
|
||||
text: { type: 'string', description: 'Message text content' },
|
||||
user: { type: 'string', description: 'User ID who sent the reply' },
|
||||
reactions: { type: 'array', description: 'Reactions on the reply' },
|
||||
files: { type: 'array', description: 'Files attached to the reply' },
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
type: 'array',
|
||||
description: 'All messages in the thread (parent + replies) in chronological order',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
replyCount: {
|
||||
type: 'number',
|
||||
description: 'Number of replies returned in this response',
|
||||
},
|
||||
hasMore: {
|
||||
type: 'boolean',
|
||||
description: 'Whether there are more messages in the thread (pagination needed)',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { slackAddReactionTool } from '@/tools/slack/add_reaction'
|
||||
import { slackCanvasTool } from '@/tools/slack/canvas'
|
||||
import { slackDeleteMessageTool } from '@/tools/slack/delete_message'
|
||||
import { slackDownloadTool } from '@/tools/slack/download'
|
||||
import { slackGetMessageTool } from '@/tools/slack/get_message'
|
||||
import { slackGetThreadTool } from '@/tools/slack/get_thread'
|
||||
import { slackGetUserTool } from '@/tools/slack/get_user'
|
||||
import { slackListChannelsTool } from '@/tools/slack/list_channels'
|
||||
import { slackListMembersTool } from '@/tools/slack/list_members'
|
||||
@@ -22,4 +24,6 @@ export {
|
||||
slackListMembersTool,
|
||||
slackListUsersTool,
|
||||
slackGetUserTool,
|
||||
slackGetMessageTool,
|
||||
slackGetThreadTool,
|
||||
}
|
||||
|
||||
@@ -71,6 +71,17 @@ export interface SlackGetUserParams extends SlackBaseParams {
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface SlackGetMessageParams extends SlackBaseParams {
|
||||
channel: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface SlackGetThreadParams extends SlackBaseParams {
|
||||
channel: string
|
||||
threadTs: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface SlackMessageResponse extends ToolResponse {
|
||||
output: {
|
||||
// Legacy properties for backward compatibility
|
||||
@@ -305,6 +316,22 @@ export interface SlackGetUserResponse extends ToolResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackGetMessageResponse extends ToolResponse {
|
||||
output: {
|
||||
message: SlackMessage
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackGetThreadResponse extends ToolResponse {
|
||||
output: {
|
||||
parentMessage: SlackMessage
|
||||
replies: SlackMessage[]
|
||||
messages: SlackMessage[]
|
||||
replyCount: number
|
||||
hasMore: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type SlackResponse =
|
||||
| SlackCanvasResponse
|
||||
| SlackMessageReaderResponse
|
||||
@@ -317,3 +344,5 @@ export type SlackResponse =
|
||||
| SlackListMembersResponse
|
||||
| SlackListUsersResponse
|
||||
| SlackGetUserResponse
|
||||
| SlackGetMessageResponse
|
||||
| SlackGetThreadResponse
|
||||
|
||||
128
apps/sim/tools/tinybird/events.ts
Normal file
128
apps/sim/tools/tinybird/events.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { gzipSync } from 'zlib'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { TinybirdEventsParams, TinybirdEventsResponse } from '@/tools/tinybird/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('tinybird-events')
|
||||
|
||||
export const eventsTool: ToolConfig<TinybirdEventsParams, TinybirdEventsResponse> = {
|
||||
id: 'tinybird_events',
|
||||
name: 'Tinybird Events',
|
||||
description:
|
||||
'Send events to a Tinybird Data Source using the Events API. Supports JSON and NDJSON formats with optional gzip compression.',
|
||||
version: '1.0.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
|
||||
params: {
|
||||
base_url: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description:
|
||||
'Tinybird API base URL (e.g., https://api.tinybird.co or https://api.us-east.tinybird.co)',
|
||||
},
|
||||
datasource: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Name of the Tinybird Data Source to send events to',
|
||||
},
|
||||
data: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Data to send as NDJSON (newline-delimited JSON) or JSON string. Each event should be a valid JSON object.',
|
||||
},
|
||||
wait: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description:
|
||||
'Wait for database acknowledgment before responding. Enables safer retries but introduces latency. Defaults to false.',
|
||||
},
|
||||
format: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Format of the events data: "ndjson" (default) or "json"',
|
||||
},
|
||||
compression: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Compression format: "none" (default) or "gzip"',
|
||||
},
|
||||
token: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Tinybird API Token with DATASOURCE:APPEND or DATASOURCE:CREATE scope',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const baseUrl = params.base_url.endsWith('/') ? params.base_url.slice(0, -1) : params.base_url
|
||||
const url = new URL(`${baseUrl}/v0/events`)
|
||||
url.searchParams.set('name', params.datasource)
|
||||
if (params.wait) {
|
||||
url.searchParams.set('wait', 'true')
|
||||
}
|
||||
return url.toString()
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
}
|
||||
|
||||
if (params.compression === 'gzip') {
|
||||
headers['Content-Encoding'] = 'gzip'
|
||||
}
|
||||
|
||||
if (params.format === 'json') {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
} else {
|
||||
headers['Content-Type'] = 'application/x-ndjson'
|
||||
}
|
||||
|
||||
return headers
|
||||
},
|
||||
body: (params) => {
|
||||
const data = params.data
|
||||
if (params.compression === 'gzip') {
|
||||
return gzipSync(Buffer.from(data, 'utf-8'))
|
||||
}
|
||||
return data
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
logger.info('Successfully sent events to Tinybird', {
|
||||
successful: data.successful_rows,
|
||||
quarantined: data.quarantined_rows,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
successful_rows: data.successful_rows ?? 0,
|
||||
quarantined_rows: data.quarantined_rows ?? 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
successful_rows: {
|
||||
type: 'number',
|
||||
description: 'Number of rows successfully ingested',
|
||||
},
|
||||
quarantined_rows: {
|
||||
type: 'number',
|
||||
description: 'Number of rows quarantined (failed validation)',
|
||||
},
|
||||
},
|
||||
}
|
||||
5
apps/sim/tools/tinybird/index.ts
Normal file
5
apps/sim/tools/tinybird/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { eventsTool } from '@/tools/tinybird/events'
|
||||
import { queryTool } from '@/tools/tinybird/query'
|
||||
|
||||
export const tinybirdEventsTool = eventsTool
|
||||
export const tinybirdQueryTool = queryTool
|
||||
139
apps/sim/tools/tinybird/query.ts
Normal file
139
apps/sim/tools/tinybird/query.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { TinybirdQueryParams, TinybirdQueryResponse } from '@/tools/tinybird/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('tinybird-query')
|
||||
|
||||
/**
|
||||
* Tinybird Query Tool
|
||||
*
|
||||
* Executes SQL queries against Tinybird and returns results in the format specified in the query.
|
||||
* - FORMAT JSON: Returns structured data with rows/statistics metadata
|
||||
* - FORMAT CSV/TSV/etc: Returns raw text string
|
||||
*
|
||||
* The tool automatically detects the response format based on Content-Type headers.
|
||||
*/
|
||||
export const queryTool: ToolConfig<TinybirdQueryParams, TinybirdQueryResponse> = {
|
||||
id: 'tinybird_query',
|
||||
name: 'Tinybird Query',
|
||||
description: 'Execute SQL queries against Tinybird Pipes and Data Sources using the Query API.',
|
||||
version: '1.0.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
|
||||
params: {
|
||||
base_url: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Tinybird API base URL (e.g., https://api.tinybird.co)',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'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: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional pipe name. When provided, enables SELECT * FROM _ syntax',
|
||||
},
|
||||
token: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Tinybird API Token with PIPE:READ scope',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const baseUrl = params.base_url.endsWith('/') ? params.base_url.slice(0, -1) : params.base_url
|
||||
return `${baseUrl}/v0/sql`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
}),
|
||||
body: (params) => {
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.set('q', params.query)
|
||||
if (params.pipeline) {
|
||||
searchParams.set('pipeline', params.pipeline)
|
||||
}
|
||||
return searchParams.toString()
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const responseText = await response.text()
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
|
||||
// Check if response is JSON based on content-type or try parsing
|
||||
const isJson = contentType.includes('application/json') || contentType.includes('text/json')
|
||||
|
||||
if (isJson) {
|
||||
try {
|
||||
const data = JSON.parse(responseText)
|
||||
logger.info('Successfully executed Tinybird query (JSON)', {
|
||||
rows: data.rows,
|
||||
elapsed: data.statistics?.elapsed,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
data: data.data || [],
|
||||
rows: data.rows || 0,
|
||||
statistics: data.statistics
|
||||
? {
|
||||
elapsed: data.statistics.elapsed,
|
||||
rows_read: data.statistics.rows_read,
|
||||
bytes_read: data.statistics.bytes_read,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
} catch (parseError) {
|
||||
logger.error('Failed to parse JSON response', {
|
||||
contentType,
|
||||
parseError: parseError instanceof Error ? parseError.message : String(parseError),
|
||||
})
|
||||
throw new Error(
|
||||
`Invalid JSON response: ${parseError instanceof Error ? parseError.message : 'Parse error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// For non-JSON formats (CSV, TSV, etc.), return as raw text
|
||||
logger.info('Successfully executed Tinybird query (non-JSON)', { contentType })
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
data: responseText,
|
||||
rows: undefined,
|
||||
statistics: undefined,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
data: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Query result data. For FORMAT JSON: array of objects. For other formats (CSV, TSV, etc.): raw text string.',
|
||||
},
|
||||
rows: {
|
||||
type: 'number',
|
||||
description: 'Number of rows returned (only available with FORMAT JSON)',
|
||||
},
|
||||
statistics: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Query execution statistics - elapsed time, rows read, bytes read (only available with FORMAT JSON)',
|
||||
},
|
||||
},
|
||||
}
|
||||
59
apps/sim/tools/tinybird/types.ts
Normal file
59
apps/sim/tools/tinybird/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Base parameters for Tinybird API tools
|
||||
*/
|
||||
export interface TinybirdBaseParams {
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for sending events to Tinybird
|
||||
*/
|
||||
export interface TinybirdEventsParams extends TinybirdBaseParams {
|
||||
base_url: string
|
||||
datasource: string
|
||||
data: string
|
||||
wait?: boolean
|
||||
format?: 'ndjson' | 'json'
|
||||
compression?: 'none' | 'gzip'
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from sending events to Tinybird
|
||||
*/
|
||||
export interface TinybirdEventsResponse extends ToolResponse {
|
||||
output: {
|
||||
successful_rows: number
|
||||
quarantined_rows: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for querying Tinybird
|
||||
*/
|
||||
export interface TinybirdQueryParams extends TinybirdBaseParams {
|
||||
base_url: string
|
||||
query: string
|
||||
pipeline?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from querying Tinybird
|
||||
*/
|
||||
export interface TinybirdQueryResponse extends ToolResponse {
|
||||
output: {
|
||||
data: unknown[] | string
|
||||
rows?: number
|
||||
statistics?: {
|
||||
elapsed: number
|
||||
rows_read: number
|
||||
bytes_read: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all possible Tinybird responses
|
||||
*/
|
||||
export type TinybirdResponse = TinybirdEventsResponse | TinybirdQueryResponse
|
||||
@@ -3,6 +3,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { AGENT, isCustomTool } from '@/executor/constants'
|
||||
import { useCustomToolsStore } from '@/stores/custom-tools'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
import { extractErrorMessage } from '@/tools/error-extractors'
|
||||
import { tools } from '@/tools/registry'
|
||||
import type { TableRow, ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
@@ -162,14 +163,22 @@ export async function executeRequest(
|
||||
const externalResponse = await fetch(url, { method, headers, body })
|
||||
|
||||
if (!externalResponse.ok) {
|
||||
let errorContent
|
||||
let errorData: any
|
||||
try {
|
||||
errorContent = await externalResponse.json()
|
||||
errorData = await externalResponse.json()
|
||||
} catch (_e) {
|
||||
errorContent = { message: externalResponse.statusText }
|
||||
try {
|
||||
errorData = await externalResponse.text()
|
||||
} catch (_e2) {
|
||||
errorData = null
|
||||
}
|
||||
}
|
||||
|
||||
const error = errorContent.message || `${toolId} API error: ${externalResponse.statusText}`
|
||||
const error = extractErrorMessage({
|
||||
status: externalResponse.status,
|
||||
statusText: externalResponse.statusText,
|
||||
data: errorData,
|
||||
})
|
||||
logger.error(`${toolId} error:`, { error })
|
||||
throw new Error(error)
|
||||
}
|
||||
|
||||
@@ -96,23 +96,3 @@ export function buildMeetingOutputs(): Record<string, TriggerOutput> {
|
||||
},
|
||||
} as Record<string, TriggerOutput>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build output schema for generic webhook events
|
||||
*/
|
||||
export function buildGenericOutputs(): Record<string, TriggerOutput> {
|
||||
return {
|
||||
payload: {
|
||||
type: 'object',
|
||||
description: 'Raw webhook payload',
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Request headers',
|
||||
},
|
||||
timestamp: {
|
||||
type: 'string',
|
||||
description: 'ISO8601 received timestamp',
|
||||
},
|
||||
} as Record<string, TriggerOutput>
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user