Compare commits

..

26 Commits

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

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

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

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

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

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

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

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

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

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

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

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

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

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

* feat(admin): routes to manage deployments

* fix naming fo deployed by

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

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

* removed unused params, cleaned up redundant utils

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

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

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

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

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

View File

@@ -577,17 +577,6 @@ export const ServiceBlock: BlockConfig = {
See the `/add-trigger` skill for creating triggers. See the `/add-trigger` skill for creating triggers.
## Icon Requirement
If the icon doesn't already exist in `@/components/icons.tsx`, **do NOT search for it yourself**. After completing the block, ask the user to provide the SVG:
```
The block is complete, but I need an icon for {Service}.
Please provide the SVG and I'll convert it to a React component.
You can usually find this in the service's brand/press kit page, or copy it from their website.
```
## Checklist Before Finishing ## Checklist Before Finishing
- [ ] All subBlocks have `id`, `title` (except switch), and `type` - [ ] All subBlocks have `id`, `title` (except switch), and `type`
@@ -599,5 +588,4 @@ You can usually find this in the service's brand/press kit page, or copy it from
- [ ] Tools.config.tool returns correct tool ID - [ ] Tools.config.tool returns correct tool ID
- [ ] Outputs match tool outputs - [ ] Outputs match tool outputs
- [ ] Block registered in registry.ts - [ ] Block registered in registry.ts
- [ ] If icon missing: asked user to provide SVG
- [ ] If triggers exist: `triggers` config set, trigger subBlocks spread - [ ] If triggers exist: `triggers` config set, trigger subBlocks spread

View File

@@ -226,26 +226,17 @@ export function {Service}Icon(props: SVGProps<SVGSVGElement>) {
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
{/* SVG paths from user-provided SVG */} {/* SVG paths from brand assets */}
</svg> </svg>
) )
} }
``` ```
### Getting Icons ### Finding Icons
**Do NOT search for icons yourself.** At the end of implementation, ask the user to provide the SVG: 1. Check the service's brand/press kit page
2. Download SVG logo
``` 3. Convert to React component
I've completed the integration. Before I can add the icon, please provide the SVG for {Service}. 4. Ensure it accepts and spreads props
You can usually find this in the service's brand/press kit page, or copy it from their website.
Paste the SVG code here and I'll convert it to a React component.
```
Once the user provides the SVG:
1. Extract the SVG paths/content
2. Create a React component that spreads props
3. Ensure viewBox is preserved from the original SVG
## Step 5: Create Triggers (Optional) ## Step 5: Create Triggers (Optional)
@@ -414,7 +405,6 @@ If creating V2 versions (API-aligned outputs):
- [ ] If triggers: spread trigger subBlocks with `getTrigger()` - [ ] If triggers: spread trigger subBlocks with `getTrigger()`
### Icon ### Icon
- [ ] Asked user to provide SVG
- [ ] Added icon to `components/icons.tsx` - [ ] Added icon to `components/icons.tsx`
- [ ] Icon spreads props correctly - [ ] Icon spreads props correctly
@@ -443,18 +433,11 @@ You: I'll add the Stripe integration. Let me:
1. First, research the Stripe API using Context7 1. First, research the Stripe API using Context7
2. Create the tools for key operations (payments, subscriptions, etc.) 2. Create the tools for key operations (payments, subscriptions, etc.)
3. Create the block with operation dropdown 3. Create the block with operation dropdown
4. Register everything 4. Add the Stripe icon
5. Generate docs 5. Register everything
6. Ask you for the Stripe icon SVG 6. Generate docs
[Proceed with implementation...] [Proceed with implementation...]
[After completing steps 1-5...]
I've completed the Stripe integration. Before I can add the icon, please provide the SVG for Stripe.
You can usually find this in the service's brand/press kit page, or copy it from their website.
Paste the SVG code here and I'll convert it to a React component.
``` ```
## Common Gotchas ## Common Gotchas

View File

@@ -552,53 +552,6 @@ All fields automatically have:
- `mode: 'trigger'` - Only shown in trigger mode - `mode: 'trigger'` - Only shown in trigger mode
- `condition: { field: 'selectedTriggerId', value: triggerId }` - Only shown when this trigger is selected - `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
Trigger outputs use the same schema as block outputs (NOT tool outputs). Trigger outputs use the same schema as block outputs (NOT tool outputs).
@@ -696,11 +649,6 @@ export const {service}WebhookTrigger: TriggerConfig = {
- [ ] Added `delete{Service}Webhook` function to `provider-subscriptions.ts` - [ ] Added `delete{Service}Webhook` function to `provider-subscriptions.ts`
- [ ] Added provider to `cleanupExternalWebhook` function - [ ] 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 ### Testing
- [ ] Run `bun run type-check` to verify no TypeScript errors - [ ] Run `bun run type-check` to verify no TypeScript errors
- [ ] Restart dev server to pick up new triggers - [ ] Restart dev server to pick up new triggers

View File

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

View File

@@ -1853,35 +1853,19 @@ export function LinearIcon(props: React.SVGProps<SVGSVGElement>) {
) )
} }
export function LangsmithIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'>
<circle cx='12' cy='12' r='10' />
</svg>
)
}
export function LemlistIcon(props: SVGProps<SVGSVGElement>) { export function LemlistIcon(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 181' fill='none'> <svg
<path {...props}
fillRule='evenodd' xmlns='http://www.w3.org/2000/svg'
clipRule='evenodd' viewBox='0 0 24 24'
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' width='24'
fill='#316BFF' height='24'
/> fill='none'
<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' <rect width='24' height='24' rx='4' fill='#316BFF' />
fill='white' <path d='M7 6h2v9h5v2H7V6Z' fill='white' />
/> <circle cx='17' cy='8' r='2' 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> </svg>
) )
} }
@@ -1905,19 +1889,6 @@ export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
) )
} }
export function TinybirdIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'>
<rect x='0' y='0' width='24' height='24' fill='#2EF598' rx='6' />
<g transform='translate(2, 2) scale(0.833)'>
<path d='M25 2.64 17.195.5 14.45 6.635z' fill='#1E7F63' />
<path d='M17.535 17.77 10.39 15.215 6.195 25.5z' fill='#1E7F63' />
<path d='M0 11.495 17.535 17.77 20.41 4.36z' fill='#1F2437' />
</g>
</svg>
)
}
export function ClayIcon(props: SVGProps<SVGSVGElement>) { export function ClayIcon(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 400 400'> <svg {...props} xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 400 400'>

View File

@@ -55,7 +55,6 @@ import {
JiraIcon, JiraIcon,
JiraServiceManagementIcon, JiraServiceManagementIcon,
KalshiIcon, KalshiIcon,
LangsmithIcon,
LemlistIcon, LemlistIcon,
LinearIcon, LinearIcon,
LinkedInIcon, LinkedInIcon,
@@ -108,7 +107,6 @@ import {
SupabaseIcon, SupabaseIcon,
TavilyIcon, TavilyIcon,
TelegramIcon, TelegramIcon,
TinybirdIcon,
TranslateIcon, TranslateIcon,
TrelloIcon, TrelloIcon,
TTSIcon, TTSIcon,
@@ -181,7 +179,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
jira_service_management: JiraServiceManagementIcon, jira_service_management: JiraServiceManagementIcon,
kalshi: KalshiIcon, kalshi: KalshiIcon,
knowledge: PackageSearchIcon, knowledge: PackageSearchIcon,
langsmith: LangsmithIcon,
lemlist: LemlistIcon, lemlist: LemlistIcon,
linear: LinearIcon, linear: LinearIcon,
linkedin: LinkedInIcon, linkedin: LinkedInIcon,
@@ -233,7 +230,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
supabase: SupabaseIcon, supabase: SupabaseIcon,
tavily: TavilyIcon, tavily: TavilyIcon,
telegram: TelegramIcon, telegram: TelegramIcon,
tinybird: TinybirdIcon,
translate: TranslateIcon, translate: TranslateIcon,
trello: TrelloIcon, trello: TrelloIcon,
tts: TTSIcon, tts: TTSIcon,

View File

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

View File

@@ -44,8 +44,6 @@ Send a message to an external A2A-compatible agent.
| `message` | string | Yes | Message to send to the agent | | `message` | string | Yes | Message to send to the agent |
| `taskId` | string | No | Task ID for continuing an existing task | | `taskId` | string | No | Task ID for continuing an existing task |
| `contextId` | string | No | Context ID for conversation continuity | | `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 | | `apiKey` | string | No | API key for authentication |
#### Output #### Output
@@ -210,3 +208,8 @@ Delete the push notification webhook configuration for a task.
| `success` | boolean | Whether deletion was successful | | `success` | boolean | Whether deletion was successful |
## Notes
- Category: `tools`
- Type: `a2a`

View File

@@ -1,59 +0,0 @@
---
title: LangSmith
description: Forward workflow runs to LangSmith for observability
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="langsmith"
color="#1B5DFF"
/>
## Usage Instructions
Send run data to LangSmith to trace executions, attach metadata, and monitor workflow performance.
## Tools
### `langsmith_create_run`
Forward a single run to LangSmith for ingestion.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LangSmith API key |
| `runId` | string | Yes | Unique run identifier |
| `name` | string | Yes | Run name |
| `runType` | string | Yes | Run type \(tool, chain, llm, retriever, embedding, prompt, parser\) |
| `startTime` | string | Yes | Run start time in ISO-8601 format |
| `endTime` | string | No | Run end time in ISO-8601 format |
#### Output
This tool does not produce any outputs.
### `langsmith_create_runs_batch`
Forward multiple runs to LangSmith in a single batch.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LangSmith API key |
| `post` | json | No | Array of new runs to ingest |
| `patch` | json | No | Array of runs to update/patch |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `accepted` | boolean | Whether the batch was accepted for ingestion |
| `runIds` | array | Run identifiers provided in the request |

View File

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

View File

@@ -52,7 +52,6 @@
"jira_service_management", "jira_service_management",
"kalshi", "kalshi",
"knowledge", "knowledge",
"langsmith",
"lemlist", "lemlist",
"linear", "linear",
"linkedin", "linkedin",
@@ -104,7 +103,6 @@
"supabase", "supabase",
"tavily", "tavily",
"telegram", "telegram",
"tinybird",
"translate", "translate",
"trello", "trello",
"tts", "tts",

View File

@@ -124,45 +124,6 @@ Read the latest messages from Slack channels. Retrieve conversation history with
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `messages` | array | Array of message objects from the channel | | `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` ### `slack_list_channels`
List all channels in a Slack workspace. Returns public and private channels the bot has access to. List all channels in a Slack workspace. Returns public and private channels the bot has access to.

View File

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

View File

@@ -97,7 +97,6 @@ const ChatMessageSchema = z.object({
}) })
) )
.optional(), .optional(),
commands: z.array(z.string()).optional(),
}) })
/** /**
@@ -133,7 +132,6 @@ export async function POST(req: NextRequest) {
provider, provider,
conversationId, conversationId,
contexts, contexts,
commands,
} = ChatMessageSchema.parse(body) } = ChatMessageSchema.parse(body)
// Ensure we have a consistent user message ID for this request // Ensure we have a consistent user message ID for this request
const userMessageIdToUse = userMessageId || crypto.randomUUID() const userMessageIdToUse = userMessageId || crypto.randomUUID()
@@ -464,7 +462,6 @@ export async function POST(req: NextRequest) {
...(integrationTools.length > 0 && { tools: integrationTools }), ...(integrationTools.length > 0 && { tools: integrationTools }),
...(baseTools.length > 0 && { baseTools }), ...(baseTools.length > 0 && { baseTools }),
...(credentials && { credentials }), ...(credentials && { credentials }),
...(commands && commands.length > 0 && { commands }),
} }
try { try {

View File

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

View File

@@ -1,4 +1,4 @@
import type { DataPart, FilePart, Message, Part, Task, TextPart } from '@a2a-js/sdk' import type { Message, Task } from '@a2a-js/sdk'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
@@ -10,20 +10,11 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('A2ASendMessageAPI') 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({ const A2ASendMessageSchema = z.object({
agentUrl: z.string().min(1, 'Agent URL is required'), agentUrl: z.string().min(1, 'Agent URL is required'),
message: z.string().min(1, 'Message is required'), message: z.string().min(1, 'Message is required'),
taskId: z.string().optional(), taskId: z.string().optional(),
contextId: z.string().optional(), contextId: z.string().optional(),
data: z.string().optional(),
files: z.array(FileInputSchema).optional(),
apiKey: z.string().optional(), apiKey: z.string().optional(),
}) })
@@ -60,100 +51,18 @@ export async function POST(request: NextRequest) {
hasContextId: !!validatedData.contextId, hasContextId: !!validatedData.contextId,
}) })
let client const client = await createA2AClient(validatedData.agentUrl, validatedData.apiKey)
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 = { const message: Message = {
kind: 'message', kind: 'message',
messageId: crypto.randomUUID(), messageId: crypto.randomUUID(),
role: 'user', role: 'user',
parts, parts: [{ kind: 'text', text: validatedData.message }],
...(validatedData.taskId && { taskId: validatedData.taskId }), ...(validatedData.taskId && { taskId: validatedData.taskId }),
...(validatedData.contextId && { contextId: validatedData.contextId }), ...(validatedData.contextId && { contextId: validatedData.contextId }),
} }
let result const result = await client.sendMessage({ message })
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') { if (result.kind === 'message') {
const responseMessage = result as Message const responseMessage = result as Message

View File

@@ -2,6 +2,13 @@ import { createSession, createWorkspaceRecord, loggerMock } from '@sim/testing'
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest' 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 mockGetSession = vi.fn()
const mockHasWorkspaceAdminAccess = vi.fn() const mockHasWorkspaceAdminAccess = vi.fn()
@@ -220,7 +227,7 @@ describe('Workspace Invitation [invitationId] API Route', () => {
expect(response.headers.get('location')).toBe('https://test.sim.ai/workspace/workspace-456/w') expect(response.headers.get('location')).toBe('https://test.sim.ai/workspace/workspace-456/w')
}) })
it('should redirect to error page with token preserved when invitation expired', async () => { it('should redirect to error page when invitation expired', async () => {
const session = createSession({ const session = createSession({
userId: mockUser.id, userId: mockUser.id,
email: 'invited@example.com', email: 'invited@example.com',
@@ -243,13 +250,12 @@ describe('Workspace Invitation [invitationId] API Route', () => {
const response = await GET(request, { params }) const response = await GET(request, { params })
expect(response.status).toBe(307) expect(response.status).toBe(307)
const location = response.headers.get('location') expect(response.headers.get('location')).toBe(
expect(location).toBe( 'https://test.sim.ai/invite/invitation-789?error=expired'
'https://test.sim.ai/invite/invitation-789?error=expired&token=token-abc123'
) )
}) })
it('should redirect to error page with token preserved when email mismatch', async () => { it('should redirect to error page when email mismatch', async () => {
const session = createSession({ const session = createSession({
userId: mockUser.id, userId: mockUser.id,
email: 'wrong@example.com', email: 'wrong@example.com',
@@ -271,13 +277,12 @@ describe('Workspace Invitation [invitationId] API Route', () => {
const response = await GET(request, { params }) const response = await GET(request, { params })
expect(response.status).toBe(307) expect(response.status).toBe(307)
const location = response.headers.get('location') expect(response.headers.get('location')).toBe(
expect(location).toBe( 'https://test.sim.ai/invite/invitation-789?error=email-mismatch'
'https://test.sim.ai/invite/invitation-789?error=email-mismatch&token=token-abc123'
) )
}) })
it('should return 404 when invitation not found (without token)', async () => { it('should return 404 when invitation not found', async () => {
const session = createSession({ userId: mockUser.id, email: mockUser.email }) const session = createSession({ userId: mockUser.id, email: mockUser.email })
mockGetSession.mockResolvedValue(session) mockGetSession.mockResolvedValue(session)
dbSelectResults = [[]] dbSelectResults = [[]]
@@ -291,189 +296,6 @@ describe('Workspace Invitation [invitationId] API Route', () => {
expect(response.status).toBe(404) expect(response.status).toBe(404)
expect(data).toEqual({ error: 'Invitation not found or has expired' }) 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]', () => { describe('DELETE /api/workspaces/invitations/[invitationId]', () => {

View File

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

View File

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

View File

@@ -221,9 +221,7 @@ export function Chat() {
exportChatCSV, exportChatCSV,
} = useChatStore() } = useChatStore()
const hasConsoleHydrated = useTerminalConsoleStore((state) => state._hasHydrated) const { entries } = useTerminalConsoleStore()
const entriesFromStore = useTerminalConsoleStore((state) => state.entries)
const entries = hasConsoleHydrated ? entriesFromStore : []
const { isExecuting } = useExecutionStore() const { isExecuting } = useExecutionStore()
const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution()
const { data: session } = useSession() const { data: session } = useSession()
@@ -533,6 +531,35 @@ export function Chat() {
return 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) finalizeMessageStream(responseMessageId)
} else if (contentChunk) { } else if (contentChunk) {
accumulatedContent += contentChunk accumulatedContent += contentChunk

View File

@@ -8,7 +8,6 @@ import {
extractFieldsFromSchema, extractFieldsFromSchema,
parseResponseFormatSafely, parseResponseFormatSafely,
} from '@/lib/core/utils/response-format' } from '@/lib/core/utils/response-format'
import { getToolOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -129,10 +128,6 @@ export function OutputSelect({
? baselineWorkflow.blocks?.[block.id]?.subBlocks?.responseFormat?.value ? baselineWorkflow.blocks?.[block.id]?.subBlocks?.responseFormat?.value
: subBlockValues?.[block.id]?.responseFormat : subBlockValues?.[block.id]?.responseFormat
const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id) const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id)
const operationValue =
shouldUseBaseline && baselineWorkflow
? baselineWorkflow.blocks?.[block.id]?.subBlocks?.operation?.value
: subBlockValues?.[block.id]?.operation
let outputsToProcess: Record<string, unknown> = {} let outputsToProcess: Record<string, unknown> = {}
@@ -146,12 +141,7 @@ export function OutputSelect({
outputsToProcess = blockConfig?.outputs || {} outputsToProcess = blockConfig?.outputs || {}
} }
} else { } else {
const toolOutputs = outputsToProcess = blockConfig?.outputs || {}
blockConfig && typeof operationValue === 'string'
? getToolOutputs(blockConfig, operationValue)
: {}
outputsToProcess =
Object.keys(toolOutputs).length > 0 ? toolOutputs : blockConfig?.outputs || {}
} }
if (Object.keys(outputsToProcess).length === 0) return if (Object.keys(outputsToProcess).length === 0) return

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import React, { memo, useCallback, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { Check, Copy } from 'lucide-react' import { Check, Copy } from 'lucide-react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
@@ -28,95 +28,55 @@ const getTextContent = (element: React.ReactNode): string => {
return '' return ''
} }
/** // Global layout fixes for markdown content inside the copilot panel
* Maps common language aliases to supported viewer languages if (typeof document !== 'undefined') {
*/ const styleId = 'copilot-markdown-fix'
const LANGUAGE_MAP: Record<string, 'javascript' | 'json' | 'python'> = { if (!document.getElementById(styleId)) {
js: 'javascript', const style = document.createElement('style')
javascript: 'javascript', style.id = styleId
jsx: 'javascript', style.textContent = `
ts: 'javascript', /* Prevent any markdown content from expanding beyond the panel */
typescript: 'javascript', .copilot-markdown-wrapper,
tsx: 'javascript', .copilot-markdown-wrapper * {
json: 'json', max-width: 100% !important;
python: 'python', }
py: 'python',
code: 'javascript', .copilot-markdown-wrapper p,
.copilot-markdown-wrapper li {
overflow-wrap: anywhere !important;
word-break: break-word !important;
}
.copilot-markdown-wrapper a {
overflow-wrap: anywhere !important;
word-break: break-all !important;
}
.copilot-markdown-wrapper code:not(pre code) {
white-space: normal !important;
overflow-wrap: anywhere !important;
word-break: break-word !important;
}
/* Reduce top margin for first heading (e.g., right after thinking block) */
.copilot-markdown-wrapper > h1:first-child,
.copilot-markdown-wrapper > h2:first-child,
.copilot-markdown-wrapper > h3:first-child,
.copilot-markdown-wrapper > h4:first-child {
margin-top: 0.25rem !important;
}
`
document.head.appendChild(style)
}
} }
/**
* Normalizes a language string to a supported viewer language
*/
function normalizeLanguage(lang: string): 'javascript' | 'json' | 'python' {
const normalized = (lang || '').toLowerCase()
return LANGUAGE_MAP[normalized] || 'javascript'
}
/**
* Props for the CodeBlock component
*/
interface CodeBlockProps {
/** Code content to display */
code: string
/** Language identifier from markdown */
language: string
}
/**
* CodeBlock component with isolated copy state
* Prevents full markdown re-renders when copy button is clicked
*/
const CodeBlock = memo(function CodeBlock({ code, language }: CodeBlockProps) {
const [copied, setCopied] = useState(false)
const handleCopy = useCallback(() => {
if (code) {
navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [code])
const viewerLanguage = normalizeLanguage(language)
const displayLanguage = language === 'code' ? viewerLanguage : language
return (
<div className='mt-2.5 mb-2.5 w-0 min-w-full overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--surface-1)] text-sm'>
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-3 py-1'>
<span className='font-season text-[var(--text-muted)] text-xs'>{displayLanguage}</span>
<button
onClick={handleCopy}
className='text-[var(--text-muted)] transition-colors hover:text-[var(--text-tertiary)]'
title='Copy'
type='button'
>
{copied ? (
<Check className='h-3 w-3' strokeWidth={2} />
) : (
<Copy className='h-3 w-3' strokeWidth={2} />
)}
</button>
</div>
<Code.Viewer
code={code.replace(/\n+$/, '')}
showGutter
language={viewerLanguage}
className='m-0 min-h-0 rounded-none border-0 bg-transparent'
/>
</div>
)
})
/** /**
* Link component with hover preview tooltip * Link component with hover preview tooltip
* Displays full URL on hover for better UX
* @param props - Component props with href and children
* @returns Link element with tooltip preview
*/ */
const LinkWithPreview = memo(function LinkWithPreview({ function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
href,
children,
}: {
href: string
children: React.ReactNode
}) {
return ( return (
<Tooltip.Root delayDuration={300}> <Tooltip.Root delayDuration={300}>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
@@ -134,7 +94,7 @@ const LinkWithPreview = memo(function LinkWithPreview({
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
) )
}) }
/** /**
* Props for the CopilotMarkdownRenderer component * Props for the CopilotMarkdownRenderer component
@@ -144,197 +104,275 @@ interface CopilotMarkdownRendererProps {
content: string content: string
} }
/**
* Static markdown component definitions - optimized for LLM chat spacing
* Tighter spacing compared to traditional prose for better chat UX
*/
const markdownComponents = {
// Paragraphs - tight spacing, no margin on last
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className='mb-1.5 font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] last:mb-0 dark:font-[470]'>
{children}
</p>
),
// Headings - minimal margins for chat context
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className='mt-2 mb-1 font-season font-semibold text-[var(--text-primary)] text-base first:mt-0'>
{children}
</h1>
),
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 className='mt-2 mb-1 font-season font-semibold text-[15px] text-[var(--text-primary)] first:mt-0'>
{children}
</h2>
),
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 className='mt-1.5 mb-0.5 font-season font-semibold text-[var(--text-primary)] text-sm first:mt-0'>
{children}
</h3>
),
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h4 className='mt-1.5 mb-0.5 font-season font-semibold text-[var(--text-primary)] text-sm first:mt-0'>
{children}
</h4>
),
// Lists - compact spacing
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
<ul
className='my-1 space-y-0.5 pl-5 font-base font-season text-[var(--text-primary)] dark:font-[470]'
style={{ listStyleType: 'disc' }}
>
{children}
</ul>
),
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
<ol
className='my-1 space-y-0.5 pl-5 font-base font-season text-[var(--text-primary)] dark:font-[470]'
style={{ listStyleType: 'decimal' }}
>
{children}
</ol>
),
li: ({ children }: React.LiHTMLAttributes<HTMLLIElement>) => (
<li
className='font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] dark:font-[470]'
style={{ display: 'list-item' }}
>
{children}
</li>
),
// Code blocks - handled by CodeBlock component
pre: ({ children }: React.HTMLAttributes<HTMLPreElement>) => {
let codeContent: React.ReactNode = children
let language = 'code'
if (
React.isValidElement<{ className?: string; children?: React.ReactNode }>(children) &&
children.type === 'code'
) {
const childElement = children as React.ReactElement<{
className?: string
children?: React.ReactNode
}>
codeContent = childElement.props.children
language = childElement.props.className?.replace('language-', '') || 'code'
}
let actualCodeText = ''
if (typeof codeContent === 'string') {
actualCodeText = codeContent
} else if (React.isValidElement(codeContent)) {
actualCodeText = getTextContent(codeContent)
} else if (Array.isArray(codeContent)) {
actualCodeText = codeContent
.map((child) =>
typeof child === 'string'
? child
: React.isValidElement(child)
? getTextContent(child)
: ''
)
.join('')
} else {
actualCodeText = String(codeContent || '')
}
return <CodeBlock code={actualCodeText} language={language} />
},
// Inline code
code: ({
className,
children,
...props
}: React.HTMLAttributes<HTMLElement> & { className?: string }) => (
<code
className='whitespace-normal break-all rounded border border-[var(--border-1)] bg-[var(--surface-1)] px-1 py-0.5 font-mono text-[0.85em] text-[var(--text-primary)]'
{...props}
>
{children}
</code>
),
// Text formatting
strong: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<strong className='font-semibold text-[var(--text-primary)]'>{children}</strong>
),
b: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<b className='font-semibold text-[var(--text-primary)]'>{children}</b>
),
em: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<em className='text-[var(--text-primary)] italic'>{children}</em>
),
i: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<i className='text-[var(--text-primary)] italic'>{children}</i>
),
// Blockquote - compact
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote className='my-1.5 border-[var(--border-1)] border-l-2 py-0.5 pl-3 font-season text-[var(--text-secondary)] text-sm italic'>
{children}
</blockquote>
),
// Horizontal rule
hr: () => <hr className='my-3 border-[var(--divider)] border-t' />,
// Links
a: ({ href, children }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<LinkWithPreview href={href || '#'}>{children}</LinkWithPreview>
),
// Tables - compact
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
<div className='my-2 max-w-full overflow-x-auto'>
<table className='min-w-full table-auto border border-[var(--border-1)] font-season text-xs'>
{children}
</table>
</div>
),
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<thead className='bg-[var(--surface-5)] text-left dark:bg-[var(--surface-4)]'>{children}</thead>
),
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<tbody className='divide-y divide-[var(--border-1)]'>{children}</tbody>
),
tr: ({ children }: React.HTMLAttributes<HTMLTableRowElement>) => (
<tr className='border-[var(--border-1)] border-b'>{children}</tr>
),
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
<th className='border-[var(--border-1)] border-r px-2 py-1 align-top font-base text-[var(--text-secondary)] last:border-r-0 dark:font-[470]'>
{children}
</th>
),
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
<td className='break-words border-[var(--border-1)] border-r px-2 py-1 align-top font-base text-[var(--text-primary)] last:border-r-0 dark:font-[470]'>
{children}
</td>
),
// Images
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img src={src} alt={alt || 'Image'} className='my-2 h-auto max-w-full rounded-md' {...props} />
),
}
/** /**
* CopilotMarkdownRenderer renders markdown content with custom styling * CopilotMarkdownRenderer renders markdown content with custom styling
* Optimized for LLM chat: tight spacing, memoized components, isolated state * Supports GitHub-flavored markdown, code blocks with syntax highlighting,
* tables, links with preview, and more
* *
* @param props - Component props * @param props - Component props
* @returns Rendered markdown content * @returns Rendered markdown content
*/ */
function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) { export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) {
const [copiedCodeBlocks, setCopiedCodeBlocks] = useState<Record<string, boolean>>({})
useEffect(() => {
const timers: Record<string, NodeJS.Timeout> = {}
Object.keys(copiedCodeBlocks).forEach((key) => {
if (copiedCodeBlocks[key]) {
timers[key] = setTimeout(() => {
setCopiedCodeBlocks((prev) => ({ ...prev, [key]: false }))
}, 2000)
}
})
return () => {
Object.values(timers).forEach(clearTimeout)
}
}, [copiedCodeBlocks])
const markdownComponents = useMemo(
() => ({
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className='mb-2 font-base font-season text-[var(--text-primary)] text-sm leading-[1.25rem] last:mb-0 dark:font-[470]'>
{children}
</p>
),
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className='mt-3 mb-3 font-season font-semibold text-2xl text-[var(--text-primary)]'>
{children}
</h1>
),
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 className='mt-2.5 mb-2.5 font-season font-semibold text-[var(--text-primary)] text-xl'>
{children}
</h2>
),
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 className='mt-2 mb-2 font-season font-semibold text-[var(--text-primary)] text-lg'>
{children}
</h3>
),
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h4 className='mt-2 mb-2 font-season font-semibold text-[var(--text-primary)] text-base'>
{children}
</h4>
),
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
<ul
className='mt-1 mb-1 space-y-1.5 pl-6 font-base font-season text-[var(--text-primary)] dark:font-[470]'
style={{ listStyleType: 'disc' }}
>
{children}
</ul>
),
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
<ol
className='mt-1 mb-1 space-y-1.5 pl-6 font-base font-season text-[var(--text-primary)] dark:font-[470]'
style={{ listStyleType: 'decimal' }}
>
{children}
</ol>
),
li: ({
children,
ordered,
}: React.LiHTMLAttributes<HTMLLIElement> & { ordered?: boolean }) => (
<li
className='font-base font-season text-[var(--text-primary)] dark:font-[470]'
style={{ display: 'list-item' }}
>
{children}
</li>
),
pre: ({ children }: React.HTMLAttributes<HTMLPreElement>) => {
let codeContent: React.ReactNode = children
let language = 'code'
if (
React.isValidElement<{ className?: string; children?: React.ReactNode }>(children) &&
children.type === 'code'
) {
const childElement = children as React.ReactElement<{
className?: string
children?: React.ReactNode
}>
codeContent = childElement.props.children
language = childElement.props.className?.replace('language-', '') || 'code'
}
let actualCodeText = ''
if (typeof codeContent === 'string') {
actualCodeText = codeContent
} else if (React.isValidElement(codeContent)) {
actualCodeText = getTextContent(codeContent)
} else if (Array.isArray(codeContent)) {
actualCodeText = codeContent
.map((child) =>
typeof child === 'string'
? child
: React.isValidElement(child)
? getTextContent(child)
: ''
)
.join('')
} else {
actualCodeText = String(codeContent || '')
}
const codeText = actualCodeText || 'code'
const codeBlockKey = `${language}-${codeText.substring(0, 30).replace(/\s/g, '-')}-${codeText.length}`
const showCopySuccess = copiedCodeBlocks[codeBlockKey] || false
const handleCopy = () => {
const textToCopy = actualCodeText
if (textToCopy) {
navigator.clipboard.writeText(textToCopy)
setCopiedCodeBlocks((prev) => ({ ...prev, [codeBlockKey]: true }))
}
}
const normalizedLanguage = (language || '').toLowerCase()
const viewerLanguage: 'javascript' | 'json' | 'python' =
normalizedLanguage === 'json'
? 'json'
: normalizedLanguage === 'python' || normalizedLanguage === 'py'
? 'python'
: 'javascript'
return (
<div className='mt-6 mb-6 w-0 min-w-full overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--surface-1)] text-sm'>
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-4 py-1.5'>
<span className='font-season text-[var(--text-muted)] text-xs'>
{language === 'code' ? viewerLanguage : language}
</span>
<button
onClick={handleCopy}
className='text-[var(--text-muted)] transition-colors hover:text-[var(--text-tertiary)]'
title='Copy'
>
{showCopySuccess ? (
<Check className='h-3 w-3' strokeWidth={2} />
) : (
<Copy className='h-3 w-3' strokeWidth={2} />
)}
</button>
</div>
<Code.Viewer
code={actualCodeText.replace(/\n+$/, '')}
showGutter
language={viewerLanguage}
className='m-0 min-h-0 rounded-none border-0 bg-transparent'
/>
</div>
)
},
code: ({
inline,
className,
children,
...props
}: React.HTMLAttributes<HTMLElement> & { className?: string; inline?: boolean }) => {
if (inline) {
return (
<code
className='whitespace-normal break-all rounded border border-[var(--border-1)] bg-[var(--surface-1)] px-1 py-0.5 font-mono text-[0.9em] text-[var(--text-primary)]'
{...props}
>
{children}
</code>
)
}
return (
<code className={className} {...props}>
{children}
</code>
)
},
strong: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<strong className='font-semibold text-[var(--text-primary)]'>{children}</strong>
),
b: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<b className='font-semibold text-[var(--text-primary)]'>{children}</b>
),
em: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<em className='text-[var(--text-primary)] italic'>{children}</em>
),
i: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<i className='text-[var(--text-primary)] italic'>{children}</i>
),
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote className='my-4 border-[var(--border-1)] border-l-4 py-1 pl-4 font-season text-[var(--text-secondary)] italic'>
{children}
</blockquote>
),
hr: () => <hr className='my-8 border-[var(--divider)] border-t' />,
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<LinkWithPreview href={href || '#'} {...props}>
{children}
</LinkWithPreview>
),
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
<div className='my-3 max-w-full overflow-x-auto'>
<table className='min-w-full table-auto border border-[var(--border-1)] font-season text-xs'>
{children}
</table>
</div>
),
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<thead className='bg-[var(--surface-5)] text-left dark:bg-[var(--surface-4)]'>
{children}
</thead>
),
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<tbody className='divide-y divide-[var(--border-1)]'>{children}</tbody>
),
tr: ({ children }: React.HTMLAttributes<HTMLTableRowElement>) => (
<tr className='border-[var(--border-1)] border-b transition-colors hover:bg-[var(--surface-5)] dark:hover:bg-[var(--surface-4)]/60'>
{children}
</tr>
),
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
<th className='border-[var(--border-1)] border-r px-2.5 py-1.5 align-top font-base text-[var(--text-secondary)] last:border-r-0 dark:font-[470]'>
{children}
</th>
),
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
<td className='break-words border-[var(--border-1)] border-r px-2.5 py-1.5 align-top font-base text-[var(--text-primary)] last:border-r-0 dark:font-[470]'>
{children}
</td>
),
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img
src={src}
alt={alt || 'Image'}
className='my-3 h-auto max-w-full rounded-md'
{...props}
/>
),
}),
[copiedCodeBlocks]
)
return ( return (
<div className='max-w-full break-words font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] dark:font-[470] [&_*]:max-w-full [&_a]:break-all [&_code:not(pre_code)]:break-words [&_li]:break-words [&_p]:break-words'> <div className='copilot-markdown-wrapper max-w-full space-y-3 break-words font-base font-season text-[var(--text-primary)] text-sm leading-[1.25rem] dark:font-[470]'>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{content} {content}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
) )
} }
export default memo(CopilotMarkdownRenderer)

View File

@@ -2,38 +2,18 @@ import { memo, useEffect, useRef, useState } from 'react'
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer' import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
/** /**
* Minimum delay between characters (fast catch-up mode) * Character animation delay in milliseconds
*/ */
const MIN_DELAY = 1 const CHARACTER_DELAY = 3
/**
* Maximum delay between characters (when waiting for content)
*/
const MAX_DELAY = 12
/**
* Default delay when streaming normally
*/
const DEFAULT_DELAY = 4
/**
* How far behind (in characters) before we speed up
*/
const CATCH_UP_THRESHOLD = 20
/**
* How close to content before we slow down
*/
const SLOW_DOWN_THRESHOLD = 5
/** /**
* StreamingIndicator shows animated dots during message streaming * StreamingIndicator shows animated dots during message streaming
* Used as a standalone indicator when no content has arrived yet * Uses CSS classes for animations to follow best practices
* *
* @returns Animated loading indicator * @returns Animated loading indicator
*/ */
export const StreamingIndicator = memo(() => ( export const StreamingIndicator = memo(() => (
<div className='flex h-[1.25rem] items-center text-muted-foreground'> <div className='flex items-center py-1 text-muted-foreground transition-opacity duration-200 ease-in-out'>
<div className='flex space-x-0.5'> <div className='flex space-x-0.5'>
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms] [animation-duration:1.2s]' /> <div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms] [animation-duration:1.2s]' />
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms] [animation-duration:1.2s]' /> <div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms] [animation-duration:1.2s]' />
@@ -54,39 +34,9 @@ interface SmoothStreamingTextProps {
isStreaming: boolean isStreaming: boolean
} }
/**
* Calculates adaptive delay based on how far behind animation is from actual content
*
* @param displayedLength - Current displayed content length
* @param totalLength - Total available content length
* @returns Delay in milliseconds
*/
function calculateAdaptiveDelay(displayedLength: number, totalLength: number): number {
const charsRemaining = totalLength - displayedLength
if (charsRemaining > CATCH_UP_THRESHOLD) {
// Far behind - speed up to catch up
// Scale from MIN_DELAY to DEFAULT_DELAY based on how far behind
const catchUpFactor = Math.min(1, (charsRemaining - CATCH_UP_THRESHOLD) / 50)
return MIN_DELAY + (DEFAULT_DELAY - MIN_DELAY) * (1 - catchUpFactor)
}
if (charsRemaining <= SLOW_DOWN_THRESHOLD) {
// Close to content edge - slow down to feel natural
// The closer we are, the slower we go (up to MAX_DELAY)
const slowFactor = 1 - charsRemaining / SLOW_DOWN_THRESHOLD
return DEFAULT_DELAY + (MAX_DELAY - DEFAULT_DELAY) * slowFactor
}
// Normal streaming speed
return DEFAULT_DELAY
}
/** /**
* SmoothStreamingText component displays text with character-by-character animation * SmoothStreamingText component displays text with character-by-character animation
* Creates a smooth streaming effect for AI responses with adaptive speed * Creates a smooth streaming effect for AI responses
*
* Uses adaptive pacing: speeds up when catching up, slows down near content edge
* *
* @param props - Component props * @param props - Component props
* @returns Streaming text with smooth animation * @returns Streaming text with smooth animation
@@ -95,73 +45,74 @@ export const SmoothStreamingText = memo(
({ content, isStreaming }: SmoothStreamingTextProps) => { ({ content, isStreaming }: SmoothStreamingTextProps) => {
const [displayedContent, setDisplayedContent] = useState('') const [displayedContent, setDisplayedContent] = useState('')
const contentRef = useRef(content) const contentRef = useRef(content)
const rafRef = useRef<number | null>(null) const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const indexRef = useRef(0) const indexRef = useRef(0)
const lastFrameTimeRef = useRef<number>(0) const streamingStartTimeRef = useRef<number | null>(null)
const isAnimatingRef = useRef(false) const isAnimatingRef = useRef(false)
/**
* Handles content streaming animation
* Updates displayed content character by character during streaming
*/
useEffect(() => { useEffect(() => {
contentRef.current = content contentRef.current = content
if (content.length === 0) { if (content.length === 0) {
setDisplayedContent('') setDisplayedContent('')
indexRef.current = 0 indexRef.current = 0
streamingStartTimeRef.current = null
return return
} }
if (isStreaming) { if (isStreaming) {
if (indexRef.current < content.length && !isAnimatingRef.current) { if (streamingStartTimeRef.current === null) {
isAnimatingRef.current = true streamingStartTimeRef.current = Date.now()
lastFrameTimeRef.current = performance.now() }
const animateText = (timestamp: number) => { if (indexRef.current < content.length) {
const animateText = () => {
const currentContent = contentRef.current const currentContent = contentRef.current
const currentIndex = indexRef.current const currentIndex = indexRef.current
const elapsed = timestamp - lastFrameTimeRef.current
// Calculate adaptive delay based on how far behind we are if (currentIndex < currentContent.length) {
const delay = calculateAdaptiveDelay(currentIndex, currentContent.length) const chunkSize = 1
const newDisplayed = currentContent.slice(0, currentIndex + chunkSize)
if (elapsed >= delay) { setDisplayedContent(newDisplayed)
if (currentIndex < currentContent.length) { indexRef.current = currentIndex + chunkSize
const newDisplayed = currentContent.slice(0, currentIndex + 1)
setDisplayedContent(newDisplayed)
indexRef.current = currentIndex + 1
lastFrameTimeRef.current = timestamp
}
}
if (indexRef.current < currentContent.length) { timeoutRef.current = setTimeout(animateText, CHARACTER_DELAY)
rafRef.current = requestAnimationFrame(animateText)
} else { } else {
isAnimatingRef.current = false isAnimatingRef.current = false
} }
} }
rafRef.current = requestAnimationFrame(animateText) if (!isAnimatingRef.current) {
} else if (indexRef.current < content.length && isAnimatingRef.current) { if (timeoutRef.current) {
// Animation already running, it will pick up new content automatically clearTimeout(timeoutRef.current)
}
isAnimatingRef.current = true
animateText()
}
} }
} else { } else {
// Streaming ended - show full content immediately
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
setDisplayedContent(content) setDisplayedContent(content)
indexRef.current = content.length indexRef.current = content.length
isAnimatingRef.current = false isAnimatingRef.current = false
streamingStartTimeRef.current = null
} }
return () => { return () => {
if (rafRef.current) { if (timeoutRef.current) {
cancelAnimationFrame(rafRef.current) clearTimeout(timeoutRef.current)
} }
isAnimatingRef.current = false isAnimatingRef.current = false
} }
}, [content, isStreaming]) }, [content, isStreaming])
return ( return (
<div className='min-h-[1.25rem] max-w-full'> <div className='relative min-h-[1.25rem] max-w-full overflow-hidden'>
<CopilotMarkdownRenderer content={displayedContent} /> <CopilotMarkdownRenderer content={displayedContent} />
</div> </div>
) )
@@ -170,6 +121,7 @@ export const SmoothStreamingText = memo(
// Prevent re-renders during streaming unless content actually changed // Prevent re-renders during streaming unless content actually changed
return ( return (
prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming
// markdownComponents is now memoized so no need to compare
) )
} }
) )

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { ChevronUp } from 'lucide-react' import { ChevronUp } from 'lucide-react'
import CopilotMarkdownRenderer from './markdown-renderer' import CopilotMarkdownRenderer from './markdown-renderer'
@@ -8,151 +8,18 @@ import CopilotMarkdownRenderer from './markdown-renderer'
/** /**
* Max height for thinking content before internal scrolling kicks in * Max height for thinking content before internal scrolling kicks in
*/ */
const THINKING_MAX_HEIGHT = 150 const THINKING_MAX_HEIGHT = 200
/**
* Height threshold before gradient fade kicks in
*/
const GRADIENT_THRESHOLD = 100
/** /**
* Interval for auto-scroll during streaming (ms) * Interval for auto-scroll during streaming (ms)
*/ */
const SCROLL_INTERVAL = 50 const SCROLL_INTERVAL = 100
/** /**
* Timer update interval in milliseconds * Timer update interval in milliseconds
*/ */
const TIMER_UPDATE_INTERVAL = 100 const TIMER_UPDATE_INTERVAL = 100
/**
* Thinking text streaming - much faster than main text
* Essentially instant with minimal delay
*/
const THINKING_DELAY = 0.5
const THINKING_CHARS_PER_FRAME = 3
/**
* Props for the SmoothThinkingText component
*/
interface SmoothThinkingTextProps {
content: string
isStreaming: boolean
}
/**
* SmoothThinkingText renders thinking content with fast streaming animation
* Uses gradient fade at top when content is tall enough
*/
const SmoothThinkingText = memo(
({ content, isStreaming }: SmoothThinkingTextProps) => {
const [displayedContent, setDisplayedContent] = useState('')
const [showGradient, setShowGradient] = useState(false)
const contentRef = useRef(content)
const textRef = useRef<HTMLDivElement>(null)
const rafRef = useRef<number | null>(null)
const indexRef = useRef(0)
const lastFrameTimeRef = useRef<number>(0)
const isAnimatingRef = useRef(false)
useEffect(() => {
contentRef.current = content
if (content.length === 0) {
setDisplayedContent('')
indexRef.current = 0
return
}
if (isStreaming) {
if (indexRef.current < content.length && !isAnimatingRef.current) {
isAnimatingRef.current = true
lastFrameTimeRef.current = performance.now()
const animateText = (timestamp: number) => {
const currentContent = contentRef.current
const currentIndex = indexRef.current
const elapsed = timestamp - lastFrameTimeRef.current
if (elapsed >= THINKING_DELAY) {
if (currentIndex < currentContent.length) {
// Reveal multiple characters per frame for faster streaming
const newIndex = Math.min(
currentIndex + THINKING_CHARS_PER_FRAME,
currentContent.length
)
const newDisplayed = currentContent.slice(0, newIndex)
setDisplayedContent(newDisplayed)
indexRef.current = newIndex
lastFrameTimeRef.current = timestamp
}
}
if (indexRef.current < currentContent.length) {
rafRef.current = requestAnimationFrame(animateText)
} else {
isAnimatingRef.current = false
}
}
rafRef.current = requestAnimationFrame(animateText)
}
} else {
// Streaming ended - show full content immediately
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
setDisplayedContent(content)
indexRef.current = content.length
isAnimatingRef.current = false
}
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
isAnimatingRef.current = false
}
}, [content, isStreaming])
// Check if content height exceeds threshold for gradient
useEffect(() => {
if (textRef.current && isStreaming) {
const height = textRef.current.scrollHeight
setShowGradient(height > GRADIENT_THRESHOLD)
} else {
setShowGradient(false)
}
}, [displayedContent, isStreaming])
// Apply vertical gradient fade at the top only when content is tall enough
const gradientStyle =
isStreaming && showGradient
? {
maskImage: 'linear-gradient(to bottom, transparent 0%, black 30%, black 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 30%, black 100%)',
}
: undefined
return (
<div
ref={textRef}
className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'
style={gradientStyle}
>
<CopilotMarkdownRenderer content={displayedContent} />
</div>
)
},
(prevProps, nextProps) => {
return (
prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming
)
}
)
SmoothThinkingText.displayName = 'SmoothThinkingText'
/** /**
* Props for the ThinkingBlock component * Props for the ThinkingBlock component
*/ */
@@ -199,8 +66,8 @@ export function ThinkingBlock({
* Auto-collapses when streaming ends OR when following content arrives * Auto-collapses when streaming ends OR when following content arrives
*/ */
useEffect(() => { useEffect(() => {
// Collapse if streaming ended, there's following content, or special tags arrived // Collapse if streaming ended or if there's following content (like a tool call)
if (!isStreaming || hasFollowingContent || hasSpecialTags) { if (!isStreaming || hasFollowingContent) {
setIsExpanded(false) setIsExpanded(false)
userCollapsedRef.current = false userCollapsedRef.current = false
setUserHasScrolledAway(false) setUserHasScrolledAway(false)
@@ -210,7 +77,7 @@ export function ThinkingBlock({
if (!userCollapsedRef.current && content && content.trim().length > 0) { if (!userCollapsedRef.current && content && content.trim().length > 0) {
setIsExpanded(true) setIsExpanded(true)
} }
}, [isStreaming, content, hasFollowingContent, hasSpecialTags]) }, [isStreaming, content, hasFollowingContent])
// Reset start time when streaming begins // Reset start time when streaming begins
useEffect(() => { useEffect(() => {
@@ -246,14 +113,14 @@ export function ThinkingBlock({
const isNearBottom = distanceFromBottom <= 20 const isNearBottom = distanceFromBottom <= 20
const delta = scrollTop - lastScrollTopRef.current const delta = scrollTop - lastScrollTopRef.current
const movedUp = delta < -1 const movedUp = delta < -2
if (movedUp && !isNearBottom) { if (movedUp && !isNearBottom) {
setUserHasScrolledAway(true) setUserHasScrolledAway(true)
} }
// Re-stick if user scrolls back to bottom with intent // Re-stick if user scrolls back to bottom
if (userHasScrolledAway && isNearBottom && delta > 10) { if (userHasScrolledAway && isNearBottom) {
setUserHasScrolledAway(false) setUserHasScrolledAway(false)
} }
@@ -266,7 +133,7 @@ export function ThinkingBlock({
return () => container.removeEventListener('scroll', handleScroll) return () => container.removeEventListener('scroll', handleScroll)
}, [isExpanded, userHasScrolledAway]) }, [isExpanded, userHasScrolledAway])
// Smart auto-scroll: always scroll to bottom while streaming unless user scrolled away // Smart auto-scroll: only scroll if user hasn't scrolled away
useEffect(() => { useEffect(() => {
if (!isStreaming || !isExpanded || userHasScrolledAway) return if (!isStreaming || !isExpanded || userHasScrolledAway) return
@@ -274,14 +141,20 @@ export function ThinkingBlock({
const container = scrollContainerRef.current const container = scrollContainerRef.current
if (!container) return if (!container) return
programmaticScrollRef.current = true const { scrollTop, scrollHeight, clientHeight } = container
container.scrollTo({ const distanceFromBottom = scrollHeight - scrollTop - clientHeight
top: container.scrollHeight, const isNearBottom = distanceFromBottom <= 50
behavior: 'auto',
}) if (isNearBottom) {
window.setTimeout(() => { programmaticScrollRef.current = true
programmaticScrollRef.current = false container.scrollTo({
}, 16) top: container.scrollHeight,
behavior: 'smooth',
})
window.setTimeout(() => {
programmaticScrollRef.current = false
}, 150)
}
}, SCROLL_INTERVAL) }, SCROLL_INTERVAL)
return () => window.clearInterval(intervalId) return () => window.clearInterval(intervalId)
@@ -368,11 +241,15 @@ export function ThinkingBlock({
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
className={clsx( className={clsx(
'overflow-y-auto transition-all duration-150 ease-out', 'overflow-y-auto transition-all duration-300 ease-in-out',
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0' isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
)} )}
> >
<SmoothThinkingText content={content} isStreaming={isStreaming && !hasFollowingContent} /> {/* Render markdown during streaming with thinking text styling */}
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.3] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 [&_br]:!leading-[0.5] [&_table]:!my-2 [&_th]:!px-2 [&_th]:!py-1 [&_th]:!text-[11px] [&_td]:!px-2 [&_td]:!py-1 [&_td]:!text-[11px] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)]'>
<CopilotMarkdownRenderer content={content} />
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-[var(--text-muted)]' />
</div>
</div> </div>
</div> </div>
) )
@@ -404,12 +281,12 @@ export function ThinkingBlock({
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
className={clsx( className={clsx(
'overflow-y-auto transition-all duration-150 ease-out', 'overflow-y-auto transition-all duration-300 ease-in-out',
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0' isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
)} )}
> >
{/* Completed thinking text - dimmed with markdown */} {/* Use markdown renderer for completed content */}
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'> <div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.3] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 [&_br]:!leading-[0.5] [&_table]:!my-2 [&_th]:!px-2 [&_th]:!py-1 [&_th]:!text-[11px] [&_td]:!px-2 [&_td]:!py-1 [&_td]:!text-[11px] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)]'>
<CopilotMarkdownRenderer content={content} /> <CopilotMarkdownRenderer content={content} />
</div> </div>
</div> </div>

View File

@@ -187,7 +187,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
) )
// Memoize content blocks to avoid re-rendering unchanged blocks // Memoize content blocks to avoid re-rendering unchanged blocks
// No entrance animations to prevent layout shift
const memoizedContentBlocks = useMemo(() => { const memoizedContentBlocks = useMemo(() => {
if (!message.contentBlocks || message.contentBlocks.length === 0) { if (!message.contentBlocks || message.contentBlocks.length === 0) {
return null return null
@@ -206,10 +205,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
// Use smooth streaming for the last text block if we're streaming // Use smooth streaming for the last text block if we're streaming
const shouldUseSmoothing = isStreaming && isLastTextBlock const shouldUseSmoothing = isStreaming && isLastTextBlock
const blockKey = `text-${index}-${block.timestamp || index}`
return ( return (
<div key={blockKey} className='w-full max-w-full'> <div
key={`text-${index}-${block.timestamp || index}`}
className={`w-full max-w-full overflow-hidden transition-opacity duration-200 ease-in-out ${
cleanBlockContent.length > 0 ? 'opacity-100' : 'opacity-70'
} ${shouldUseSmoothing ? 'translate-y-0 transition-transform duration-100 ease-out' : ''}`}
>
{shouldUseSmoothing ? ( {shouldUseSmoothing ? (
<SmoothStreamingText content={cleanBlockContent} isStreaming={isStreaming} /> <SmoothStreamingText content={cleanBlockContent} isStreaming={isStreaming} />
) : ( ) : (
@@ -221,33 +224,29 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
if (block.type === 'thinking') { if (block.type === 'thinking') {
// Check if there are any blocks after this one (tool calls, text, etc.) // Check if there are any blocks after this one (tool calls, text, etc.)
const hasFollowingContent = index < message.contentBlocks!.length - 1 const hasFollowingContent = index < message.contentBlocks!.length - 1
// Check if special tags (options, plan) are present - should also close thinking
const hasSpecialTags = !!(parsedTags?.options || parsedTags?.plan)
const blockKey = `thinking-${index}-${block.timestamp || index}`
return ( return (
<div key={blockKey} className='w-full'> <div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'>
<ThinkingBlock <ThinkingBlock
content={block.content} content={block.content}
isStreaming={isStreaming} isStreaming={isStreaming}
hasFollowingContent={hasFollowingContent} hasFollowingContent={hasFollowingContent}
hasSpecialTags={hasSpecialTags}
/> />
</div> </div>
) )
} }
if (block.type === 'tool_call') { if (block.type === 'tool_call') {
const blockKey = `tool-${block.toolCall.id}`
return ( return (
<div key={blockKey}> <div
key={`tool-${block.toolCall.id}`}
className='opacity-100 transition-opacity duration-300 ease-in-out'
>
<ToolCall toolCallId={block.toolCall.id} toolCall={block.toolCall} /> <ToolCall toolCallId={block.toolCall.id} toolCall={block.toolCall} />
</div> </div>
) )
} }
return null return null
}) })
}, [message.contentBlocks, isStreaming, parsedTags]) }, [message.contentBlocks, isStreaming])
if (isUser) { if (isUser) {
return ( return (
@@ -280,7 +279,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
onModeChange={setMode} onModeChange={setMode}
panelWidth={panelWidth} panelWidth={panelWidth}
clearOnSubmit={false} clearOnSubmit={false}
initialContexts={message.contexts}
/> />
{/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */} {/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */}
@@ -348,18 +346,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
const contexts: any[] = Array.isArray((message as any).contexts) const contexts: any[] = Array.isArray((message as any).contexts)
? ((message as any).contexts as any[]) ? ((message as any).contexts as any[])
: [] : []
const labels = contexts
// Build tokens with their prefixes (@ for mentions, / for commands) .filter((c) => c?.kind !== 'current_workflow')
const tokens = contexts .map((c) => c?.label)
.filter((c) => c?.kind !== 'current_workflow' && c?.label) .filter(Boolean) as string[]
.map((c) => { if (!labels.length) return text
const prefix = c?.kind === 'slash_command' ? '/' : '@'
return `${prefix}${c.label}`
})
if (!tokens.length) return text
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g') const pattern = new RegExp(`@(${labels.map(escapeRegex).join('|')})`, 'g')
const nodes: React.ReactNode[] = [] const nodes: React.ReactNode[] = []
let lastIndex = 0 let lastIndex = 0
@@ -466,29 +460,17 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
) )
} }
// Check if there's any visible content in the blocks
const hasVisibleContent = useMemo(() => {
if (!message.contentBlocks || message.contentBlocks.length === 0) return false
return message.contentBlocks.some((block) => {
if (block.type === 'text') {
const parsed = parseSpecialTags(block.content)
return parsed.cleanContent.trim().length > 0
}
return block.type === 'thinking' || block.type === 'tool_call'
})
}, [message.contentBlocks])
if (isAssistant) { if (isAssistant) {
return ( return (
<div <div
className={`w-full max-w-full overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`} className={`w-full max-w-full overflow-hidden transition-opacity duration-200 [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties} style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
> >
<div className='max-w-full space-y-1 px-[2px]'> <div className='max-w-full space-y-1.5 px-[2px] transition-all duration-200 ease-in-out'>
{/* Content blocks in chronological order */} {/* Content blocks in chronological order */}
{memoizedContentBlocks} {memoizedContentBlocks}
{/* Streaming indicator always at bottom during streaming */} {/* Always show streaming indicator at the end while streaming */}
{isStreaming && <StreamingIndicator />} {isStreaming && <StreamingIndicator />}
{message.errorType === 'usage_limit' && ( {message.errorType === 'usage_limit' && (

View File

@@ -8,6 +8,7 @@ import { Button, Code, getCodeEditorProps, highlight, languages } from '@/compon
import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
import { getClientTool } from '@/lib/copilot/tools/client/manager' import { getClientTool } from '@/lib/copilot/tools/client/manager'
import { getRegisteredTools } from '@/lib/copilot/tools/client/registry' import { getRegisteredTools } from '@/lib/copilot/tools/client/registry'
// Initialize all tool UI configs
import '@/lib/copilot/tools/client/init-tool-configs' import '@/lib/copilot/tools/client/init-tool-configs'
import { import {
getSubagentLabels as getSubagentLabelsFromConfig, getSubagentLabels as getSubagentLabelsFromConfig,
@@ -496,11 +497,6 @@ const ACTION_VERBS = [
'Accessed', 'Accessed',
'Managing', 'Managing',
'Managed', 'Managed',
'Scraping',
'Scraped',
'Crawling',
'Crawled',
'Getting',
] as const ] as const
/** /**
@@ -1065,7 +1061,7 @@ function SubAgentContent({
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
className={clsx( className={clsx(
'overflow-y-auto transition-all duration-150 ease-out', 'overflow-y-auto transition-all duration-300 ease-in-out',
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0' isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
)} )}
> >
@@ -1161,10 +1157,10 @@ function SubAgentThinkingContent({
/** /**
* Subagents that should collapse when done streaming. * Subagents that should collapse when done streaming.
* Default behavior is to NOT collapse (stay expanded like edit, superagent, info, etc.). * Default behavior is to NOT collapse (stay expanded like edit).
* Only plan, debug, and research collapse into summary headers. * Only these specific subagents collapse into "Planned for Xs >" style headers.
*/ */
const COLLAPSIBLE_SUBAGENTS = new Set(['plan', 'debug', 'research']) const COLLAPSIBLE_SUBAGENTS = new Set(['plan', 'debug', 'research', 'info'])
/** /**
* SubagentContentRenderer handles the rendering of subagent content. * SubagentContentRenderer handles the rendering of subagent content.
@@ -1325,7 +1321,7 @@ function SubagentContentRenderer({
<div <div
className={clsx( className={clsx(
'overflow-hidden transition-all duration-150 ease-out', 'overflow-hidden transition-all duration-300 ease-in-out',
isExpanded ? 'mt-1.5 max-h-[5000px] opacity-100' : 'max-h-0 opacity-0' isExpanded ? 'mt-1.5 max-h-[5000px] opacity-100' : 'max-h-0 opacity-0'
)} )}
> >
@@ -1635,8 +1631,10 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) {
* Checks if a tool is an integration tool (server-side executed, not a client tool) * Checks if a tool is an integration tool (server-side executed, not a client tool)
*/ */
function isIntegrationTool(toolName: string): boolean { function isIntegrationTool(toolName: string): boolean {
// Any tool NOT in CLASS_TOOL_METADATA is an integration tool (server-side execution) // Check if it's NOT a client tool (not in CLASS_TOOL_METADATA and not in registered tools)
return !CLASS_TOOL_METADATA[toolName] const isClientTool = !!CLASS_TOOL_METADATA[toolName]
const isRegisteredTool = !!getRegisteredTools()[toolName]
return !isClientTool && !isRegisteredTool
} }
function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean { function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
@@ -1665,9 +1663,16 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
return true return true
} }
// Always show buttons for integration tools in pending state (they need user confirmation) // Also show buttons for integration tools in pending state (they need user confirmation)
// But NOT if the tool is auto-allowed (it will auto-execute)
const mode = useCopilotStore.getState().mode const mode = useCopilotStore.getState().mode
if (mode === 'build' && isIntegrationTool(toolCall.name) && toolCall.state === 'pending') { const isAutoAllowed = useCopilotStore.getState().isToolAutoAllowed(toolCall.name)
if (
mode === 'build' &&
isIntegrationTool(toolCall.name) &&
toolCall.state === 'pending' &&
!isAutoAllowed
) {
return true return true
} }
@@ -1890,20 +1895,15 @@ function RunSkipButtons({
if (buttonsHidden) return null if (buttonsHidden) return null
// Hide "Always Allow" for integration tools (only show for client tools with interrupts) // Standardized buttons for all interrupt tools: Allow, Always Allow, Skip
const showAlwaysAllow = !isIntegrationTool(toolCall.name)
// Standardized buttons for all interrupt tools: Allow, (Always Allow for client tools only), Skip
return ( return (
<div className='mt-1.5 flex gap-[6px]'> <div className='mt-1.5 flex gap-[6px]'>
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'> <Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
{isProcessing ? 'Allowing...' : 'Allow'} {isProcessing ? 'Allowing...' : 'Allow'}
</Button> </Button>
{showAlwaysAllow && ( <Button onClick={onAlwaysAllow} disabled={isProcessing} variant='default'>
<Button onClick={onAlwaysAllow} disabled={isProcessing} variant='default'> {isProcessing ? 'Allowing...' : 'Always Allow'}
{isProcessing ? 'Allowing...' : 'Always Allow'} </Button>
</Button>
)}
<Button onClick={onSkip} disabled={isProcessing} variant='default'> <Button onClick={onSkip} disabled={isProcessing} variant='default'>
Skip Skip
</Button> </Button>
@@ -1969,7 +1969,6 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
'tour', 'tour',
'info', 'info',
'workflow', 'workflow',
'superagent',
] ]
const isSubagentTool = SUBAGENT_TOOLS.includes(toolCall.name) const isSubagentTool = SUBAGENT_TOOLS.includes(toolCall.name)
@@ -2597,23 +2596,16 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
} }
} }
// For edit_workflow, hide text display when we have operations (WorkflowEditSummary replaces it)
const isEditWorkflow = toolCall.name === 'edit_workflow'
const hasOperations = Array.isArray(params.operations) && params.operations.length > 0
const hideTextForEditWorkflow = isEditWorkflow && hasOperations
return ( return (
<div className='w-full'> <div className='w-full'>
{!hideTextForEditWorkflow && ( <div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}>
<div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}> <ShimmerOverlayText
<ShimmerOverlayText text={displayName}
text={displayName} active={isLoadingState}
active={isLoadingState} isSpecial={isSpecial}
isSpecial={isSpecial} className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]' />
/> </div>
</div>
)}
{isExpandableTool && expanded && <div className='mt-1.5'>{renderPendingDetails()}</div>} {isExpandableTool && expanded && <div className='mt-1.5'>{renderPendingDetails()}</div>}
{showRemoveAutoAllow && isAutoAllowed && ( {showRemoveAutoAllow && isAutoAllowed && (
<div className='mt-1.5'> <div className='mt-1.5'>

View File

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

View File

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

View File

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

View File

@@ -1,207 +0,0 @@
'use client'
import { useEffect, useMemo } from 'react'
import {
Popover,
PopoverAnchor,
PopoverBackButton,
PopoverContent,
PopoverFolder,
PopoverItem,
PopoverScrollArea,
usePopoverContext,
} from '@/components/emcn'
import {
ALL_SLASH_COMMANDS,
MENU_STATE_TEXT_CLASSES,
TOP_LEVEL_COMMANDS,
WEB_COMMANDS,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import { useCaretViewport } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import type { useMentionMenu } from '../../hooks/use-mention-menu'
export interface SlashFolderNav {
isInFolder: boolean
openWebFolder: () => void
closeFolder: () => void
}
interface SlashMenuProps {
mentionMenu: ReturnType<typeof useMentionMenu>
message: string
onSelectCommand: (command: string) => void
onFolderNavChange?: (nav: SlashFolderNav) => void
}
function SlashMenuContent({
mentionMenu,
message,
onSelectCommand,
onFolderNavChange,
}: SlashMenuProps) {
const { currentFolder, openFolder, closeFolder } = usePopoverContext()
const {
menuListRef,
getActiveSlashQueryAtPosition,
getCaretPos,
submenuActiveIndex,
mentionActiveIndex,
setSubmenuActiveIndex,
} = mentionMenu
const caretPos = getCaretPos()
const currentQuery = useMemo(() => {
const active = getActiveSlashQueryAtPosition(caretPos, message)
return active?.query.trim().toLowerCase() || ''
}, [message, caretPos, getActiveSlashQueryAtPosition])
const filteredCommands = useMemo(() => {
if (!currentQuery) return null
return ALL_SLASH_COMMANDS.filter(
(cmd) =>
cmd.id.toLowerCase().includes(currentQuery) ||
cmd.label.toLowerCase().includes(currentQuery)
)
}, [currentQuery])
const showAggregatedView = currentQuery.length > 0
const isInFolder = currentFolder !== null
const isInFolderNavigationMode = !isInFolder && !showAggregatedView
useEffect(() => {
if (onFolderNavChange) {
onFolderNavChange({
isInFolder,
openWebFolder: () => {
openFolder('web', 'Web')
setSubmenuActiveIndex(0)
},
closeFolder: () => {
closeFolder()
setSubmenuActiveIndex(0)
},
})
}
}, [onFolderNavChange, isInFolder, openFolder, closeFolder, setSubmenuActiveIndex])
return (
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{isInFolder ? (
<>
{WEB_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</>
) : showAggregatedView ? (
<>
{filteredCommands && filteredCommands.length === 0 ? (
<div className={MENU_STATE_TEXT_CLASSES}>No commands found</div>
) : (
filteredCommands?.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))
)}
</>
) : (
<>
{TOP_LEVEL_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={isInFolderNavigationMode && index === mentionActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
<PopoverFolder
id='web'
title='Web'
onOpen={() => setSubmenuActiveIndex(0)}
active={isInFolderNavigationMode && mentionActiveIndex === TOP_LEVEL_COMMANDS.length}
data-idx={TOP_LEVEL_COMMANDS.length}
>
{WEB_COMMANDS.map((cmd) => (
<PopoverItem key={cmd.id} onClick={() => onSelectCommand(cmd.id)}>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</PopoverFolder>
</>
)}
</PopoverScrollArea>
)
}
export function SlashMenu({
mentionMenu,
message,
onSelectCommand,
onFolderNavChange,
}: SlashMenuProps) {
const { mentionMenuRef, textareaRef, getCaretPos } = mentionMenu
const caretPos = getCaretPos()
const { caretViewport, side } = useCaretViewport({
textareaRef,
message,
caretPos,
})
if (!caretViewport) return null
return (
<Popover open={true} onOpenChange={() => {}}>
<PopoverAnchor asChild>
<div
style={{
position: 'fixed',
top: `${caretViewport.top}px`,
left: `${caretViewport.left}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
/>
</PopoverAnchor>
<PopoverContent
ref={mentionMenuRef}
side={side}
align='start'
collisionPadding={6}
maxHeight={360}
className='pointer-events-auto'
style={{ width: '180px' }}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onMouseDown={(e) => e.preventDefault()}
>
<PopoverBackButton />
<SlashMenuContent
mentionMenu={mentionMenu}
message={message}
onSelectCommand={onSelectCommand}
onFolderNavChange={onFolderNavChange}
/>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,245 +1,42 @@
import type { ChatContext } from '@/stores/panel' /**
* Constants for user input component
*/
/** /**
* Mention folder types * Mention menu options in order (matches visual render order)
*/ */
export type MentionFolderId = export const MENTION_OPTIONS = [
| 'chats' 'Chats',
| 'workflows' 'Workflows',
| 'knowledge' 'Knowledge',
| 'blocks' 'Blocks',
| 'workflow-blocks' 'Workflow Blocks',
| 'templates' 'Templates',
| 'logs' 'Logs',
'Docs',
/**
* 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 ] 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 * Model configuration options
*/ */
export const MODEL_OPTIONS = [ export const MODEL_OPTIONS = [
{ value: 'claude-4.5-opus', label: 'Claude 4.5 Opus' }, { value: 'claude-4.5-opus', label: 'Claude 4.5 Opus' },
{ value: 'claude-4.5-sonnet', label: 'Claude 4.5 Sonnet' }, { 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.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.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.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' }, { value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
] as const ] as const
@@ -252,18 +49,3 @@ export const NEAR_TOP_THRESHOLD = 300
* Scroll tolerance for mention menu positioning (in pixels) * Scroll tolerance for mention menu positioning (in pixels)
*/ */
export const SCROLL_TOLERANCE = 8 export const SCROLL_TOLERANCE = 8
/**
* Shared CSS classes for menu state text (loading, empty states)
*/
export const MENU_STATE_TEXT_CLASSES = 'px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'
/**
* Calculates the next index for circular navigation (wraps around at bounds)
*/
export function getNextIndex(current: number, direction: 'up' | 'down', maxIndex: number): number {
if (direction === 'down') {
return current >= maxIndex ? 0 : current + 1
}
return current <= 0 ? maxIndex : current - 1
}

View File

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

View File

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

View File

@@ -1,15 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, 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' import type { ChatContext } from '@/stores/panel'
interface UseContextManagementProps { interface UseContextManagementProps {
/** Current message text */ /** Current message text */
message: string message: string
/** Initial contexts to populate when editing a message */
initialContexts?: ChatContext[]
} }
/** /**
@@ -19,17 +13,8 @@ interface UseContextManagementProps {
* @param props - Configuration object * @param props - Configuration object
* @returns Context state and management functions * @returns Context state and management functions
*/ */
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) { export function useContextManagement({ message }: UseContextManagementProps) {
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? []) const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>([])
const initializedRef = useRef(false)
// Initialize with initial contexts when they're first provided (for edit mode)
useEffect(() => {
if (initialContexts && initialContexts.length > 0 && !initializedRef.current) {
setSelectedContexts(initialContexts)
initializedRef.current = true
}
}, [initialContexts])
/** /**
* Adds a context to the selected contexts list, avoiding duplicates * Adds a context to the selected contexts list, avoiding duplicates
@@ -39,7 +24,50 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
*/ */
const addContext = useCallback((context: ChatContext) => { const addContext = useCallback((context: ChatContext) => {
setSelectedContexts((prev) => { setSelectedContexts((prev) => {
if (isContextAlreadySelected(context, prev)) return prev // CRITICAL: Check label collision FIRST
// The token system uses @label format, so we cannot have duplicate labels
// regardless of kind or ID differences
const exists = prev.some((c) => {
// Primary check: label collision
// This prevents duplicate @Label tokens which would break the overlay
if (c.label && context.label && c.label === context.label) {
return true
}
// Secondary check: exact duplicate by ID fields based on kind
// This prevents the same entity from being added twice even with different labels
if (c.kind === context.kind) {
if (c.kind === 'past_chat' && 'chatId' in context && 'chatId' in c) {
return c.chatId === (context as any).chatId
}
if (c.kind === 'workflow' && 'workflowId' in context && 'workflowId' in c) {
return c.workflowId === (context as any).workflowId
}
if (c.kind === 'blocks' && 'blockId' in context && 'blockId' in c) {
return c.blockId === (context as any).blockId
}
if (c.kind === 'workflow_block' && 'blockId' in context && 'blockId' in c) {
return (
c.workflowId === (context as any).workflowId && c.blockId === (context as any).blockId
)
}
if (c.kind === 'knowledge' && 'knowledgeId' in context && 'knowledgeId' in c) {
return c.knowledgeId === (context as any).knowledgeId
}
if (c.kind === 'templates' && 'templateId' in context && 'templateId' in c) {
return c.templateId === (context as any).templateId
}
if (c.kind === 'logs' && 'executionId' in context && 'executionId' in c) {
return c.executionId === (context as any).executionId
}
if (c.kind === 'docs') {
return true // Only one docs context allowed
}
}
return false
})
if (exists) return prev
return [...prev, context] return [...prev, context]
}) })
}, []) }, [])
@@ -50,7 +78,36 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
* @param contextToRemove - Context to remove * @param contextToRemove - Context to remove
*/ */
const removeContext = useCallback((contextToRemove: ChatContext) => { const removeContext = useCallback((contextToRemove: ChatContext) => {
setSelectedContexts((prev) => filterOutContext(prev, contextToRemove)) setSelectedContexts((prev) =>
prev.filter((c) => {
// Match by kind and specific ID fields
if (c.kind !== contextToRemove.kind) return true
switch (c.kind) {
case 'past_chat':
return (c as any).chatId !== (contextToRemove as any).chatId
case 'workflow':
return (c as any).workflowId !== (contextToRemove as any).workflowId
case 'blocks':
return (c as any).blockId !== (contextToRemove as any).blockId
case 'workflow_block':
return (
(c as any).workflowId !== (contextToRemove as any).workflowId ||
(c as any).blockId !== (contextToRemove as any).blockId
)
case 'knowledge':
return (c as any).knowledgeId !== (contextToRemove as any).knowledgeId
case 'templates':
return (c as any).templateId !== (contextToRemove as any).templateId
case 'logs':
return (c as any).executionId !== (contextToRemove as any).executionId
case 'docs':
return false // Remove docs (only one docs context)
default:
return c.label !== contextToRemove.label
}
})
)
}, []) }, [])
/** /**
@@ -61,7 +118,7 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
}, []) }, [])
/** /**
* Synchronizes selected contexts with inline @label or /label tokens in the message. * Synchronizes selected contexts with inline @label tokens in the message.
* Removes contexts whose labels are no longer present in the message. * Removes contexts whose labels are no longer present in the message.
*/ */
useEffect(() => { useEffect(() => {
@@ -73,16 +130,17 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
setSelectedContexts((prev) => { setSelectedContexts((prev) => {
if (prev.length === 0) return prev if (prev.length === 0) return prev
const filtered = prev.filter((c) => { const presentLabels = new Set<string>()
if (!c.label) return false const labels = prev.map((c) => c.label).filter(Boolean)
// Check for slash command tokens or mention tokens based on kind
const isSlashCommand = c.kind === 'slash_command' for (const label of labels) {
const prefix = isSlashCommand ? '/' : '@' const token = ` @${label} `
const tokenWithSpaces = ` ${prefix}${c.label} ` if (message.includes(token)) {
const tokenAtStart = `${prefix}${c.label} ` presentLabels.add(label)
// Token can appear with leading space OR at the start of the message }
return message.includes(tokenWithSpaces) || message.startsWith(tokenAtStart) }
})
const filtered = prev.filter((c) => !!c.label && presentLabels.has(c.label))
return filtered.length === prev.length ? prev : filtered return filtered.length === prev.length ? prev : filtered
}) })
}, [message]) }, [message])

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { SCROLL_TOLERANCE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants' import { createLogger } from '@sim/logger'
import type { ChatContext } from '@/stores/panel' import type { ChatContext } from '@/stores/panel'
import { SCROLL_TOLERANCE } from '../constants'
const logger = createLogger('useMentionMenu')
interface UseMentionMenuProps { interface UseMentionMenuProps {
/** Current message text */ /** Current message text */
@@ -67,25 +70,11 @@ export function useMentionMenu({
// Ensure '@' starts a token (start or whitespace before) // Ensure '@' starts a token (start or whitespace before)
if (atIndex > 0 && !/\s/.test(before.charAt(atIndex - 1))) return null if (atIndex > 0 && !/\s/.test(before.charAt(atIndex - 1))) return null
// Check if this '@' is part of a completed mention token // Check if this '@' is part of a completed mention token ( @label )
if (selectedContexts.length > 0) { if (selectedContexts.length > 0) {
// Only check non-slash_command contexts for mentions const labels = selectedContexts.map((c) => c.label).filter(Boolean) as string[]
const mentionLabels = selectedContexts for (const label of labels) {
.filter((c) => c.kind !== 'slash_command') // Space-wrapped token: " @label "
.map((c) => c.label)
.filter(Boolean) as string[]
for (const label of mentionLabels) {
// Check for token at start of text: "@label "
if (atIndex === 0) {
const startToken = `@${label} `
if (text.startsWith(startToken)) {
// This @ is part of a completed token
return null
}
}
// Check for space-wrapped token: " @label "
const token = ` @${label} ` const token = ` @${label} `
let fromIndex = 0 let fromIndex = 0
while (fromIndex <= text.length) { while (fromIndex <= text.length) {
@@ -99,6 +88,7 @@ export function useMentionMenu({
// Check if the @ we found is the @ of this completed token // Check if the @ we found is the @ of this completed token
if (atIndex === atPositionInToken) { if (atIndex === atPositionInToken) {
// The @ we found is part of a completed mention // The @ we found is part of a completed mention
// Don't show menu - user is typing after the completed mention
return null return null
} }
@@ -123,76 +113,6 @@ export function useMentionMenu({
[message, selectedContexts] [message, selectedContexts]
) )
/**
* Finds active slash command query at the given position
*
* @param pos - Position in the text to check
* @param textOverride - Optional text override (for checking during input)
* @returns Active slash query object or null if no active slash command
*/
const getActiveSlashQueryAtPosition = useCallback(
(pos: number, textOverride?: string) => {
const text = textOverride ?? message
const before = text.slice(0, pos)
const slashIndex = before.lastIndexOf('/')
if (slashIndex === -1) return null
// Ensure '/' starts a token (start or whitespace before)
if (slashIndex > 0 && !/\s/.test(before.charAt(slashIndex - 1))) return null
// Check if this '/' is part of a completed slash token
if (selectedContexts.length > 0) {
// Only check slash_command contexts
const slashLabels = selectedContexts
.filter((c) => c.kind === 'slash_command')
.map((c) => c.label)
.filter(Boolean) as string[]
for (const label of slashLabels) {
// Check for token at start of text: "/label "
if (slashIndex === 0) {
const startToken = `/${label} `
if (text.startsWith(startToken)) {
// This slash is part of a completed token
return null
}
}
// Check for space-wrapped token: " /label "
const token = ` /${label} `
let fromIndex = 0
while (fromIndex <= text.length) {
const idx = text.indexOf(token, fromIndex)
if (idx === -1) break
const tokenStart = idx
const tokenEnd = idx + token.length
const slashPositionInToken = idx + 1 // position of / in " /label "
if (slashIndex === slashPositionInToken) {
return null
}
if (pos > tokenStart && pos < tokenEnd) {
return null
}
fromIndex = tokenEnd
}
}
}
const segment = before.slice(slashIndex + 1)
// Close the popup if user types space immediately after /
if (segment.length > 0 && /^\s/.test(segment)) {
return null
}
return { query: segment, start: slashIndex, end: pos }
},
[message, selectedContexts]
)
/** /**
* Gets the submenu query text * Gets the submenu query text
* *
@@ -280,10 +200,9 @@ export function useMentionMenu({
const before = message.slice(0, active.start) const before = message.slice(0, active.start)
const after = message.slice(active.end) const after = message.slice(active.end)
// Add leading space only if not at start and previous char isn't whitespace // Always include leading space, avoid duplicate if one exists
const needsLeadingSpace = before.length > 0 && !before.endsWith(' ') const needsLeadingSpace = !before.endsWith(' ')
// Always add trailing space for easy continued typing const insertion = `${needsLeadingSpace ? ' ' : ''}@${label} `
const insertion = `${needsLeadingSpace ? ' ' : ''}@${label} `
const next = `${before}${insertion}${after}` const next = `${before}${insertion}${after}`
onMessageChange(next) onMessageChange(next)
@@ -298,41 +217,6 @@ export function useMentionMenu({
[message, getActiveMentionQueryAtPosition, onMessageChange] [message, getActiveMentionQueryAtPosition, onMessageChange]
) )
/**
* Replaces active slash command with a label
*
* @param label - Label to replace the slash command with
* @returns True if replacement was successful, false if no active slash command found
*/
const replaceActiveSlashWith = useCallback(
(label: string) => {
const textarea = textareaRef.current
if (!textarea) return false
const pos = textarea.selectionStart ?? message.length
const active = getActiveSlashQueryAtPosition(pos)
if (!active) return false
const before = message.slice(0, active.start)
const after = message.slice(active.end)
// Add leading space only if not at start and previous char isn't whitespace
const needsLeadingSpace = before.length > 0 && !before.endsWith(' ')
// Always add trailing space for easy continued typing
const insertion = `${needsLeadingSpace ? ' ' : ''}/${label} `
const next = `${before}${insertion}${after}`
onMessageChange(next)
setTimeout(() => {
const cursorPos = before.length + insertion.length
textarea.setSelectionRange(cursorPos, cursorPos)
textarea.focus()
}, 0)
return true
},
[message, getActiveSlashQueryAtPosition, onMessageChange]
)
/** /**
* Scrolls active item into view in the menu * Scrolls active item into view in the menu
* *
@@ -420,12 +304,10 @@ export function useMentionMenu({
// Operations // Operations
getCaretPos, getCaretPos,
getActiveMentionQueryAtPosition, getActiveMentionQueryAtPosition,
getActiveSlashQueryAtPosition,
getSubmenuQuery, getSubmenuQuery,
resetActiveMentionQuery, resetActiveMentionQuery,
insertAtCursor, insertAtCursor,
replaceActiveMentionWith, replaceActiveMentionWith,
replaceActiveSlashWith,
scrollActiveItemIntoView, scrollActiveItemIntoView,
closeMentionMenu, closeMentionMenu,
} }

View File

@@ -39,7 +39,7 @@ export function useMentionTokens({
setSelectedContexts, setSelectedContexts,
}: UseMentionTokensProps) { }: UseMentionTokensProps) {
/** /**
* Computes all mention ranges in the message (both @mentions and /commands) * Computes all mention ranges in the message
* *
* @returns Array of mention ranges sorted by start position * @returns Array of mention ranges sorted by start position
*/ */
@@ -55,19 +55,8 @@ export function useMentionTokens({
const uniqueLabels = Array.from(new Set(labels)) const uniqueLabels = Array.from(new Set(labels))
for (const label of uniqueLabels) { for (const label of uniqueLabels) {
// Find matching context to determine if it's a slash command // Space-wrapped token: " @label " (search from start)
const matchingContext = selectedContexts.find((c) => c.label === label) const token = ` @${label} `
const isSlashCommand = matchingContext?.kind === 'slash_command'
const prefix = isSlashCommand ? '/' : '@'
// Check for token at the very start of the message (no leading space)
const tokenAtStart = `${prefix}${label} `
if (message.startsWith(tokenAtStart)) {
ranges.push({ start: 0, end: tokenAtStart.length, label })
}
// Space-wrapped token: " @label " or " /label " (search from start)
const token = ` ${prefix}${label} `
let fromIndex = 0 let fromIndex = 0
while (fromIndex <= message.length) { while (fromIndex <= message.length) {
const idx = message.indexOf(token, fromIndex) const idx = message.indexOf(token, fromIndex)

View File

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

View File

@@ -18,21 +18,11 @@ import { cn } from '@/lib/core/utils/cn'
import { import {
AttachedFilesDisplay, AttachedFilesDisplay,
ContextPills, ContextPills,
type MentionFolderNav,
MentionMenu, MentionMenu,
ModelSelector, ModelSelector,
ModeSelector, ModeSelector,
type SlashFolderNav,
SlashMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components' } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import { import { NEAR_TOP_THRESHOLD } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
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 { import {
useContextManagement, useContextManagement,
useFileAttachments, useFileAttachments,
@@ -77,8 +67,6 @@ interface UserInputProps {
hideModeSelector?: boolean hideModeSelector?: boolean
/** Disable @mention functionality */ /** Disable @mention functionality */
disableMentions?: boolean disableMentions?: boolean
/** Initial contexts for editing a message with existing context mentions */
initialContexts?: ChatContext[]
} }
interface UserInputRef { interface UserInputRef {
@@ -115,10 +103,10 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
onModelChangeOverride, onModelChangeOverride,
hideModeSelector = false, hideModeSelector = false,
disableMentions = false, disableMentions = false,
initialContexts,
}, },
ref ref
) => { ) => {
// Refs and external hooks
const { data: session } = useSession() const { data: session } = useSession()
const params = useParams() const params = useParams()
const workspaceId = params.workspaceId as string const workspaceId = params.workspaceId as string
@@ -130,18 +118,18 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
selectedModelOverride !== undefined ? selectedModelOverride : copilotStore.selectedModel selectedModelOverride !== undefined ? selectedModelOverride : copilotStore.selectedModel
const setSelectedModel = onModelChangeOverride || copilotStore.setSelectedModel const setSelectedModel = onModelChangeOverride || copilotStore.setSelectedModel
// Internal state
const [internalMessage, setInternalMessage] = useState('') const [internalMessage, setInternalMessage] = useState('')
const [isNearTop, setIsNearTop] = useState(false) const [isNearTop, setIsNearTop] = useState(false)
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null) const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const [inputContainerRef, setInputContainerRef] = 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 message = controlledValue !== undefined ? controlledValue : internalMessage
const setMessage = const setMessage =
controlledValue !== undefined ? onControlledChange || (() => {}) : setInternalMessage controlledValue !== undefined ? onControlledChange || (() => {}) : setInternalMessage
// Effective placeholder
const effectivePlaceholder = const effectivePlaceholder =
placeholder || placeholder ||
(mode === 'ask' (mode === 'ask'
@@ -150,8 +138,11 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
? 'Plan your workflow' ? 'Plan your workflow'
: 'Plan, search, build anything') : 'Plan, search, build anything')
const contextManagement = useContextManagement({ message, initialContexts }) // Custom hooks - order matters for ref sharing
// Context management (manages selectedContexts state)
const contextManagement = useContextManagement({ message })
// Mention menu
const mentionMenu = useMentionMenu({ const mentionMenu = useMentionMenu({
message, message,
selectedContexts: contextManagement.selectedContexts, selectedContexts: contextManagement.selectedContexts,
@@ -159,6 +150,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
onMessageChange: setMessage, onMessageChange: setMessage,
}) })
// Mention token utilities
const mentionTokensWithContext = useMentionTokens({ const mentionTokensWithContext = useMentionTokens({
message, message,
selectedContexts: contextManagement.selectedContexts, selectedContexts: contextManagement.selectedContexts,
@@ -186,21 +178,22 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
isLoading, isLoading,
}) })
// Insert mention handlers
const insertHandlers = useMentionInsertHandlers({ const insertHandlers = useMentionInsertHandlers({
mentionMenu, mentionMenu,
workflowId: workflowId || null, workflowId: workflowId || null,
selectedContexts: contextManagement.selectedContexts, selectedContexts: contextManagement.selectedContexts,
onContextAdd: contextManagement.addContext, onContextAdd: contextManagement.addContext,
mentionFolderNav,
}) })
// Keyboard navigation hook
const mentionKeyboard = useMentionKeyboard({ const mentionKeyboard = useMentionKeyboard({
mentionMenu, mentionMenu,
mentionData, mentionData,
insertHandlers, insertHandlers,
mentionFolderNav,
}) })
// Expose focus method to parent
useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({
@@ -217,6 +210,17 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
[mentionMenu.textareaRef] [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(() => { useEffect(() => {
const checkPosition = () => { const checkPosition = () => {
if (containerRef) { if (containerRef) {
@@ -244,6 +248,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
} }
}, [containerRef]) }, [containerRef])
// Also check position when mention menu opens
useEffect(() => { useEffect(() => {
if (mentionMenu.showMentionMenu && containerRef) { if (mentionMenu.showMentionMenu && containerRef) {
const rect = containerRef.getBoundingClientRect() const rect = containerRef.getBoundingClientRect()
@@ -251,8 +256,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
} }
}, [mentionMenu.showMentionMenu, containerRef]) }, [mentionMenu.showMentionMenu, containerRef])
// Preload mention data when query is active
useEffect(() => { useEffect(() => {
if (!mentionMenu.showMentionMenu || mentionFolderNav?.isInFolder) { if (!mentionMenu.showMentionMenu || mentionMenu.openSubmenuFor) {
return return
} }
@@ -262,31 +268,38 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
.toLowerCase() .toLowerCase()
if (q && q.length > 0) { if (q && q.length > 0) {
// Prefetch all lists when there's any query for instant filtering
void mentionData.ensurePastChatsLoaded() void mentionData.ensurePastChatsLoaded()
// workflows and workflow-blocks auto-load from stores void mentionData.ensureWorkflowsLoaded()
void mentionData.ensureWorkflowBlocksLoaded()
void mentionData.ensureKnowledgeLoaded() void mentionData.ensureKnowledgeLoaded()
void mentionData.ensureBlocksLoaded() void mentionData.ensureBlocksLoaded()
void mentionData.ensureTemplatesLoaded() void mentionData.ensureTemplatesLoaded()
void mentionData.ensureLogsLoaded() void mentionData.ensureLogsLoaded()
// Reset to first item when query changes
mentionMenu.setSubmenuActiveIndex(0) mentionMenu.setSubmenuActiveIndex(0)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [mentionMenu.showMentionMenu, mentionFolderNav?.isInFolder, message]) }, [mentionMenu.showMentionMenu, mentionMenu.openSubmenuFor, message])
// When switching into a submenu, select the first item and scroll to it
useEffect(() => { useEffect(() => {
if (mentionFolderNav?.isInFolder) { if (mentionMenu.openSubmenuFor) {
mentionMenu.setSubmenuActiveIndex(0) mentionMenu.setSubmenuActiveIndex(0)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0)) requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0))
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [mentionFolderNav?.isInFolder]) }, [mentionMenu.openSubmenuFor])
// Handlers
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => { async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
const targetMessage = overrideMessage ?? message const targetMessage = overrideMessage ?? message
const trimmedMessage = targetMessage.trim() const trimmedMessage = targetMessage.trim()
// Allow submission even when isLoading - store will queue the message
if (!trimmedMessage || disabled) return if (!trimmedMessage || disabled) return
const failedUploads = fileAttachments.attachedFiles.filter((f) => !f.uploading && !f.key) const failedUploads = fileAttachments.attachedFiles.filter((f) => !f.uploading && !f.key)
@@ -357,125 +370,28 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
} }
}, [onAbort, isLoading]) }, [onAbort, isLoading])
const handleSlashCommandSelect = useCallback(
(command: string) => {
const displayLabel = getCommandDisplayLabel(command)
mentionMenu.replaceActiveSlashWith(displayLabel)
contextManagement.addContext({
kind: 'slash_command',
command,
label: displayLabel,
})
setShowSlashMenu(false)
mentionMenu.textareaRef.current?.focus()
},
[mentionMenu, contextManagement]
)
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => { (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) { // Escape key handling
if (e.key === 'Escape' && mentionMenu.showMentionMenu) {
e.preventDefault() e.preventDefault()
if (mentionFolderNav?.isInFolder) { if (mentionMenu.openSubmenuFor) {
mentionFolderNav.closeFolder() mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setSubmenuQueryStart(null) mentionMenu.setSubmenuQueryStart(null)
} else if (slashFolderNav?.isInFolder) {
slashFolderNav.closeFolder()
} else { } else {
mentionMenu.closeMentionMenu() mentionMenu.closeMentionMenu()
setShowSlashMenu(false)
} }
return return
} }
if (showSlashMenu) { // Arrow navigation in mention menu
const caretPos = mentionMenu.getCaretPos()
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
const query = activeSlash?.query.trim().toLowerCase() || ''
const showAggregatedView = query.length > 0
const direction = e.key === 'ArrowDown' ? 'down' : 'up'
const isInFolder = slashFolderNav?.isInFolder ?? false
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
if (isInFolder) {
mentionMenu.setSubmenuActiveIndex((prev) => {
const next = getNextIndex(prev, direction, WEB_COMMANDS.length - 1)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
} else if (showAggregatedView) {
const filtered = ALL_COMMAND_IDS.filter((cmd) => cmd.includes(query))
mentionMenu.setSubmenuActiveIndex((prev) => {
if (filtered.length === 0) return 0
const next = getNextIndex(prev, direction, filtered.length - 1)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
} else {
mentionMenu.setMentionActiveIndex((prev) => {
const next = getNextIndex(prev, direction, TOP_LEVEL_COMMANDS.length)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
}
return
}
if (e.key === 'ArrowRight') {
e.preventDefault()
if (!showAggregatedView && !isInFolder) {
if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) {
slashFolderNav?.openWebFolder()
}
}
return
}
if (e.key === 'ArrowLeft') {
e.preventDefault()
if (isInFolder) {
slashFolderNav?.closeFolder()
}
return
}
}
if (mentionKeyboard.handleArrowNavigation(e)) return if (mentionKeyboard.handleArrowNavigation(e)) return
if (mentionKeyboard.handleArrowRight(e)) return if (mentionKeyboard.handleArrowRight(e)) return
if (mentionKeyboard.handleArrowLeft(e)) return if (mentionKeyboard.handleArrowLeft(e)) return
// Enter key handling
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault() e.preventDefault()
if (showSlashMenu) {
const caretPos = mentionMenu.getCaretPos()
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
const query = activeSlash?.query.trim().toLowerCase() || ''
const showAggregatedView = query.length > 0
const isInFolder = slashFolderNav?.isInFolder ?? false
if (isInFolder) {
const selectedCommand =
WEB_COMMANDS[mentionMenu.submenuActiveIndex]?.id || WEB_COMMANDS[0].id
handleSlashCommandSelect(selectedCommand)
} else if (showAggregatedView) {
const filtered = ALL_COMMAND_IDS.filter((cmd) => cmd.includes(query))
if (filtered.length > 0) {
const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0]
handleSlashCommandSelect(selectedCommand)
}
} else {
const selectedIndex = mentionMenu.mentionActiveIndex
if (selectedIndex < TOP_LEVEL_COMMANDS.length) {
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex].id)
} else if (selectedIndex === TOP_LEVEL_COMMANDS.length) {
slashFolderNav?.openWebFolder()
}
}
return
}
if (!mentionMenu.showMentionMenu) { if (!mentionMenu.showMentionMenu) {
handleSubmit() handleSubmit()
} else { } else {
@@ -484,6 +400,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
return return
} }
// Handle mention token behavior (backspace, delete, arrow keys) when menu is closed
if (!mentionMenu.showMentionMenu) { if (!mentionMenu.showMentionMenu) {
const textarea = mentionMenu.textareaRef.current const textarea = mentionMenu.textareaRef.current
const selStart = textarea?.selectionStart ?? 0 const selStart = textarea?.selectionStart ?? 0
@@ -492,8 +409,11 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
if (e.key === 'Backspace' || e.key === 'Delete') { if (e.key === 'Backspace' || e.key === 'Delete') {
if (selectionLength > 0) { 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) mentionTokensWithContext.removeContextsInSelection(selStart, selEnd)
} else { } else {
// Single character delete - check if cursor is inside/at a mention token
const ranges = mentionTokensWithContext.computeMentionRanges() const ranges = mentionTokensWithContext.computeMentionRanges()
const target = const target =
e.key === 'Backspace' e.key === 'Backspace'
@@ -532,6 +452,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
} }
} }
// Prevent typing inside token
if (e.key.length === 1 || e.key === 'Space') { if (e.key.length === 1 || e.key === 'Space') {
const blocked = const blocked =
selectionLength === 0 && !!mentionTokensWithContext.findRangeContaining(selStart) selectionLength === 0 && !!mentionTokensWithContext.findRangeContaining(selStart)
@@ -548,17 +469,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
} }
} }
}, },
[ [mentionMenu, mentionKeyboard, handleSubmit, message.length, mentionTokensWithContext]
mentionMenu,
mentionKeyboard,
handleSubmit,
handleSlashCommandSelect,
message,
mentionTokensWithContext,
showSlashMenu,
slashFolderNav,
mentionFolderNav,
]
) )
const handleInputChange = useCallback( const handleInputChange = useCallback(
@@ -566,36 +477,28 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const newValue = e.target.value const newValue = e.target.value
setMessage(newValue) setMessage(newValue)
// Skip mention menu logic if mentions are disabled
if (disableMentions) return if (disableMentions) return
const caret = e.target.selectionStart ?? newValue.length const caret = e.target.selectionStart ?? newValue.length
const activeMention = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue) const active = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue)
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caret, newValue)
if (activeMention) { if (active) {
setShowSlashMenu(false)
mentionMenu.setShowMentionMenu(true) mentionMenu.setShowMentionMenu(true)
mentionMenu.setInAggregated(false) mentionMenu.setInAggregated(false)
if (mentionFolderNav?.isInFolder) { if (mentionMenu.openSubmenuFor) {
mentionMenu.setSubmenuActiveIndex(0) mentionMenu.setSubmenuActiveIndex(0)
} else { } else {
mentionMenu.setMentionActiveIndex(0) mentionMenu.setMentionActiveIndex(0)
mentionMenu.setSubmenuActiveIndex(0) mentionMenu.setSubmenuActiveIndex(0)
} }
} else if (activeSlash) {
mentionMenu.setShowMentionMenu(false)
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setSubmenuQueryStart(null)
setShowSlashMenu(true)
mentionMenu.setSubmenuActiveIndex(0)
} else { } else {
mentionMenu.setShowMentionMenu(false) mentionMenu.setShowMentionMenu(false)
mentionMenu.setOpenSubmenuFor(null) mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setSubmenuQueryStart(null) mentionMenu.setSubmenuQueryStart(null)
setShowSlashMenu(false)
} }
}, },
[setMessage, mentionMenu, disableMentions, mentionFolderNav] [setMessage, mentionMenu, disableMentions]
) )
const handleSelectAdjust = useCallback(() => { const handleSelectAdjust = useCallback(() => {
@@ -611,66 +514,58 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
} }
}, [mentionMenu.textareaRef, mentionTokensWithContext]) }, [mentionMenu.textareaRef, mentionTokensWithContext])
const insertTriggerAndOpenMenu = useCallback( const handleOpenMentionMenuWithAt = useCallback(() => {
(trigger: '@' | '/') => { if (disabled || isLoading) return
if (disabled || isLoading) return const textarea = mentionMenu.textareaRef.current
const textarea = mentionMenu.textareaRef.current if (!textarea) return
if (!textarea) return textarea.focus()
const pos = textarea.selectionStart ?? message.length
const needsSpaceBefore = pos > 0 && !/\s/.test(message.charAt(pos - 1))
const insertText = needsSpaceBefore ? ' @' : '@'
const start = textarea.selectionStart ?? message.length
const end = textarea.selectionEnd ?? message.length
const before = message.slice(0, start)
const after = message.slice(end)
const next = `${before}${insertText}${after}`
setMessage(next)
setTimeout(() => {
const newPos = before.length + insertText.length
textarea.setSelectionRange(newPos, newPos)
textarea.focus() textarea.focus()
const start = textarea.selectionStart ?? message.length }, 0)
const end = textarea.selectionEnd ?? message.length
const needsSpaceBefore = start > 0 && !/\s/.test(message.charAt(start - 1))
const insertText = needsSpaceBefore ? ` ${trigger}` : trigger mentionMenu.setShowMentionMenu(true)
const before = message.slice(0, start) mentionMenu.setOpenSubmenuFor(null)
const after = message.slice(end) mentionMenu.setMentionActiveIndex(0)
setMessage(`${before}${insertText}${after}`) mentionMenu.setSubmenuActiveIndex(0)
}, [disabled, isLoading, mentionMenu, message, setMessage])
setTimeout(() => {
const newPos = before.length + insertText.length
textarea.setSelectionRange(newPos, newPos)
textarea.focus()
}, 0)
if (trigger === '@') {
mentionMenu.setShowMentionMenu(true)
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setMentionActiveIndex(0)
} else {
setShowSlashMenu(true)
}
mentionMenu.setSubmenuActiveIndex(0)
},
[disabled, isLoading, mentionMenu, message, setMessage]
)
const handleOpenMentionMenuWithAt = useCallback(
() => insertTriggerAndOpenMenu('@'),
[insertTriggerAndOpenMenu]
)
const handleOpenSlashMenu = useCallback(
() => insertTriggerAndOpenMenu('/'),
[insertTriggerAndOpenMenu]
)
const canSubmit = message.trim().length > 0 && !disabled && !isLoading const canSubmit = message.trim().length > 0 && !disabled && !isLoading
const showAbortButton = isLoading && onAbort const showAbortButton = isLoading && onAbort
// Render overlay content with highlighted mentions
const renderOverlayContent = useCallback(() => { const renderOverlayContent = useCallback(() => {
const contexts = contextManagement.selectedContexts const contexts = contextManagement.selectedContexts
// Handle empty message
if (!message) { if (!message) {
return <span>{'\u00A0'}</span> return <span>{'\u00A0'}</span>
} }
// If no contexts, render the message directly with proper newline handling
if (contexts.length === 0) { 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 const displayText = message.endsWith('\n') ? `${message}\u200B` : message
return <span>{displayText}</span> return <span>{displayText}</span>
} }
const elements: React.ReactNode[] = [] 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() const ranges = mentionTokensWithContext.computeMentionRanges()
if (ranges.length === 0) { if (ranges.length === 0) {
@@ -682,11 +577,14 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
for (let i = 0; i < ranges.length; i++) { for (let i = 0; i < ranges.length; i++) {
const range = ranges[i] const range = ranges[i]
// Add text before mention
if (range.start > lastIndex) { if (range.start > lastIndex) {
const before = message.slice(lastIndex, range.start) const before = message.slice(lastIndex, range.start)
elements.push(<span key={`text-${i}-${lastIndex}-${range.start}`}>{before}</span>) 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) const mentionText = message.slice(range.start, range.end)
elements.push( elements.push(
<span <span
@@ -701,10 +599,12 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const tail = message.slice(lastIndex) const tail = message.slice(lastIndex)
if (tail) { if (tail) {
// Add a zero-width space at the end if tail ends with newline
const displayTail = tail.endsWith('\n') ? `${tail}\u200B` : tail const displayTail = tail.endsWith('\n') ? `${tail}\u200B` : tail
elements.push(<span key={`tail-${lastIndex}`}>{displayTail}</span>) 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> return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
}, [message, contextManagement.selectedContexts, mentionTokensWithContext]) }, [message, contextManagement.selectedContexts, mentionTokensWithContext])
@@ -743,20 +643,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
<AtSign className='h-3 w-3' strokeWidth={1.75} /> <AtSign className='h-3 w-3' strokeWidth={1.75} />
</Badge> </Badge>
<Badge
variant='outline'
onClick={handleOpenSlashMenu}
title='Insert /'
className={cn(
'cursor-pointer rounded-[6px] p-[4.5px]',
(disabled || isLoading) && 'cursor-not-allowed'
)}
>
<span className='flex h-3 w-3 items-center justify-center font-medium text-[11px] leading-none'>
/
</span>
</Badge>
{/* Selected Context Pills */} {/* Selected Context Pills */}
<ContextPills <ContextPills
contexts={contextManagement.selectedContexts} contexts={contextManagement.selectedContexts}
@@ -828,20 +714,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
mentionData={mentionData} mentionData={mentionData}
message={message} message={message}
insertHandlers={insertHandlers} insertHandlers={insertHandlers}
onFolderNavChange={setMentionFolderNav}
/>,
document.body
)}
{/* Slash Menu Portal */}
{!disableMentions &&
showSlashMenu &&
createPortal(
<SlashMenu
mentionMenu={mentionMenu}
message={message}
onSelectCommand={handleSlashCommandSelect}
onFolderNavChange={setSlashFolderNav}
/>, />,
document.body document.body
)} )}

View File

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

View File

@@ -20,13 +20,7 @@ import {
extractFieldsFromSchema, extractFieldsFromSchema,
parseResponseFormatSafely, parseResponseFormatSafely,
} from '@/lib/core/utils/response-format' } from '@/lib/core/utils/response-format'
import { import { getBlockOutputPaths, getBlockOutputType } from '@/lib/workflows/blocks/block-outputs'
getBlockOutputPaths,
getBlockOutputType,
getOutputPathsFromSchema,
getToolOutputPaths,
getToolOutputType,
} from '@/lib/workflows/blocks/block-outputs'
import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers' import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers'
import { KeyboardNavigationHandler } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler' import { KeyboardNavigationHandler } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler'
import type { import type {
@@ -44,6 +38,7 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { normalizeName } from '@/stores/workflows/utils' import { normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types' import type { BlockState } from '@/stores/workflows/workflow/types'
import { getTool } from '@/tools/utils'
const logger = createLogger('TagDropdown') const logger = createLogger('TagDropdown')
@@ -73,12 +68,6 @@ interface TagDropdownProps {
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement> inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement>
} }
interface TagComputationResult {
tags: string[]
variableInfoMap: Record<string, { type: string; id: string }>
blockTagGroups: BlockTagGroup[]
}
/** /**
* Checks if the tag trigger (`<`) should show the tag dropdown. * Checks if the tag trigger (`<`) should show the tag dropdown.
* *
@@ -229,6 +218,161 @@ const getOutputTypeForPath = (
return 'any' return 'any'
} }
/**
* Recursively generates all output paths from an outputs schema.
*
* @remarks
* Traverses nested objects and arrays to build dot-separated paths
* for all leaf values in the schema.
*
* @param outputs - The outputs schema object
* @param prefix - Current path prefix for recursion
* @returns Array of dot-separated paths to all output fields
*/
const generateOutputPaths = (outputs: Record<string, any>, prefix = ''): string[] => {
const paths: string[] = []
for (const [key, value] of Object.entries(outputs)) {
const currentPath = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
paths.push(currentPath)
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
const hasNestedProperties =
((value.type === 'object' || value.type === 'json') && value.properties) ||
(value.type === 'array' && value.items?.properties) ||
(value.type === 'array' &&
value.items &&
typeof value.items === 'object' &&
!('type' in value.items))
if (!hasNestedProperties) {
paths.push(currentPath)
}
if ((value.type === 'object' || value.type === 'json') && value.properties) {
paths.push(...generateOutputPaths(value.properties, currentPath))
} else if (value.type === 'array' && value.items?.properties) {
paths.push(...generateOutputPaths(value.items.properties, currentPath))
} else if (
value.type === 'array' &&
value.items &&
typeof value.items === 'object' &&
!('type' in value.items)
) {
paths.push(...generateOutputPaths(value.items, currentPath))
}
} else {
const subPaths = generateOutputPaths(value, currentPath)
paths.push(...subPaths)
}
} else {
paths.push(currentPath)
}
}
return paths
}
/**
* Recursively generates all output paths with their types from an outputs schema.
*
* @remarks
* Similar to generateOutputPaths but also captures the type information
* for each path, useful for displaying type hints in the UI.
*
* @param outputs - The outputs schema object
* @param prefix - Current path prefix for recursion
* @returns Array of objects containing path and type for each output field
*/
const generateOutputPathsWithTypes = (
outputs: Record<string, any>,
prefix = ''
): Array<{ path: string; type: string }> => {
const paths: Array<{ path: string; type: string }> = []
for (const [key, value] of Object.entries(outputs)) {
const currentPath = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
paths.push({ path: currentPath, type: value })
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
if (value.type === 'array' && value.items?.properties) {
paths.push({ path: currentPath, type: 'array' })
const subPaths = generateOutputPathsWithTypes(value.items.properties, currentPath)
paths.push(...subPaths)
} else if ((value.type === 'object' || value.type === 'json') && value.properties) {
paths.push({ path: currentPath, type: value.type })
const subPaths = generateOutputPathsWithTypes(value.properties, currentPath)
paths.push(...subPaths)
} else {
paths.push({ path: currentPath, type: value.type })
}
} else {
const subPaths = generateOutputPathsWithTypes(value, currentPath)
paths.push(...subPaths)
}
} else {
paths.push({ path: currentPath, type: 'any' })
}
}
return paths
}
/**
* Generates output paths for a tool-based block.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @returns Array of output paths for the tool, or empty array on error
*/
const generateToolOutputPaths = (blockConfig: BlockConfig, operation: string): string[] => {
if (!blockConfig?.tools?.config?.tool) return []
try {
const toolId = blockConfig.tools.config.tool({ operation })
if (!toolId) return []
const toolConfig = getTool(toolId)
if (!toolConfig?.outputs) return []
return generateOutputPaths(toolConfig.outputs)
} catch (error) {
logger.warn('Failed to get tool outputs for operation', { operation, error })
return []
}
}
/**
* Gets the output type for a specific path in a tool's outputs.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @param path - The dot-separated path to the output field
* @returns The type of the output field, or 'any' if not found
*/
const getToolOutputType = (blockConfig: BlockConfig, operation: string, path: string): string => {
if (!blockConfig?.tools?.config?.tool) return 'any'
try {
const toolId = blockConfig.tools.config.tool({ operation })
if (!toolId) return 'any'
const toolConfig = getTool(toolId)
if (!toolConfig?.outputs) return 'any'
const pathsWithTypes = generateOutputPathsWithTypes(toolConfig.outputs)
const matchingPath = pathsWithTypes.find((p) => p.path === path)
return matchingPath?.type || 'any'
} catch (error) {
logger.warn('Failed to get tool output type for path', { path, error })
return 'any'
}
}
/** /**
* Calculates the viewport position of the caret in a textarea/input. * Calculates the viewport position of the caret in a textarea/input.
* *
@@ -457,16 +601,14 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
[inputValue, cursorPosition] [inputValue, cursorPosition]
) )
const emptyVariableInfoMap: Record<string, { type: string; id: string }> = {}
/** /**
* Computes tags, variable info, and block tag groups * Computes tags, variable info, and block tag groups
*/ */
const { tags, variableInfoMap, blockTagGroups } = useMemo<TagComputationResult>(() => { const { tags, variableInfoMap, blockTagGroups } = useMemo(() => {
if (activeSourceBlockId) { if (activeSourceBlockId) {
const sourceBlock = blocks[activeSourceBlockId] const sourceBlock = blocks[activeSourceBlockId]
if (!sourceBlock) { if (!sourceBlock) {
return { tags: [], variableInfoMap: emptyVariableInfoMap, blockTagGroups: [] } return { tags: [], variableInfoMap: {}, blockTagGroups: [] }
} }
const blockConfig = getBlock(sourceBlock.type) const blockConfig = getBlock(sourceBlock.type)
@@ -477,7 +619,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const blockName = sourceBlock.name || sourceBlock.type const blockName = sourceBlock.name || sourceBlock.type
const normalizedBlockName = normalizeName(blockName) const normalizedBlockName = normalizeName(blockName)
const outputPaths = getOutputPathsFromSchema(mockConfig.outputs) const outputPaths = generateOutputPaths(mockConfig.outputs)
const blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) const blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
const blockTagGroups: BlockTagGroup[] = [ const blockTagGroups: BlockTagGroup[] = [
@@ -490,9 +632,9 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}, },
] ]
return { tags: blockTags, variableInfoMap: emptyVariableInfoMap, blockTagGroups } return { tags: blockTags, variableInfoMap: {}, blockTagGroups }
} }
return { tags: [], variableInfoMap: emptyVariableInfoMap, blockTagGroups: [] } return { tags: [], variableInfoMap: {}, blockTagGroups: [] }
} }
const blockName = sourceBlock.name || sourceBlock.type const blockName = sourceBlock.name || sourceBlock.type
@@ -635,7 +777,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const operationValue = const operationValue =
mergedSubBlocks?.operation?.value ?? getSubBlockValue(activeSourceBlockId, 'operation') mergedSubBlocks?.operation?.value ?? getSubBlockValue(activeSourceBlockId, 'operation')
const toolOutputPaths = operationValue const toolOutputPaths = operationValue
? getToolOutputPaths(blockConfig, operationValue) ? generateToolOutputPaths(blockConfig, operationValue)
: [] : []
if (toolOutputPaths.length > 0) { if (toolOutputPaths.length > 0) {
@@ -668,12 +810,12 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}, },
] ]
return { tags: blockTags, variableInfoMap: emptyVariableInfoMap, blockTagGroups } return { tags: blockTags, variableInfoMap: {}, blockTagGroups }
} }
const hasInvalidBlocks = Object.values(blocks).some((block) => !block || !block.type) const hasInvalidBlocks = Object.values(blocks).some((block) => !block || !block.type)
if (hasInvalidBlocks) { if (hasInvalidBlocks) {
return { tags: [], variableInfoMap: emptyVariableInfoMap, blockTagGroups: [] } return { tags: [], variableInfoMap: {}, blockTagGroups: [] }
} }
const starterBlock = Object.values(blocks).find((block) => block.type === 'starter') const starterBlock = Object.values(blocks).find((block) => block.type === 'starter')
@@ -839,7 +981,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const blockName = accessibleBlock.name || accessibleBlock.type const blockName = accessibleBlock.name || accessibleBlock.type
const normalizedBlockName = normalizeName(blockName) const normalizedBlockName = normalizeName(blockName)
const outputPaths = getOutputPathsFromSchema(mockConfig.outputs) const outputPaths = generateOutputPaths(mockConfig.outputs)
let blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) let blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
blockTags = ensureRootTag(blockTags, normalizedBlockName) blockTags = ensureRootTag(blockTags, normalizedBlockName)
@@ -967,7 +1109,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const operationValue = const operationValue =
mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation') mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation')
const toolOutputPaths = operationValue const toolOutputPaths = operationValue
? getToolOutputPaths(blockConfig, operationValue) ? generateToolOutputPaths(blockConfig, operationValue)
: [] : []
if (toolOutputPaths.length > 0) { if (toolOutputPaths.length > 0) {
@@ -1041,7 +1183,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const filteredTags = useMemo(() => { const filteredTags = useMemo(() => {
if (!searchTerm) return tags if (!searchTerm) return tags
return tags.filter((tag: string) => tag.toLowerCase().includes(searchTerm)) return tags.filter((tag) => tag.toLowerCase().includes(searchTerm))
}, [tags, searchTerm]) }, [tags, searchTerm])
const { variableTags, filteredBlockTagGroups } = useMemo(() => { const { variableTags, filteredBlockTagGroups } = useMemo(() => {

View File

@@ -36,7 +36,6 @@ import {
Tooltip, Tooltip,
} from '@/components/emcn' } from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env' 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 { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { import {
@@ -83,6 +82,18 @@ const COLUMN_WIDTHS = {
OUTPUT_PANEL: 'w-[400px]', OUTPUT_PANEL: 'w-[400px]',
} as const } 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 * Shared styling constants
*/ */
@@ -172,6 +183,22 @@ const ToggleButton = ({
</Button> </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 * Truncates execution ID for display as run ID
*/ */
@@ -181,25 +208,16 @@ const formatRunId = (executionId?: string): string => {
} }
/** /**
* Run ID colors * Gets color for a run ID based on its index in the execution ID order map
*/ */
const RUN_ID_COLORS = [ const getRunIdColor = (
'#4ADE80', // Green executionId: string | undefined,
'#F472B6', // Pink executionIdOrderMap: Map<string, number>
'#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 if (!executionId) return null
return colorMap.get(executionId) ?? null const colorIndex = executionIdOrderMap.get(executionId)
if (colorIndex === undefined) return null
return RUN_ID_COLORS[colorIndex % RUN_ID_COLORS.length]
} }
/** /**
@@ -302,14 +320,12 @@ export function Terminal() {
} = useTerminalStore() } = useTerminalStore()
const isExpanded = useTerminalStore((state) => state.terminalHeight > NEAR_MIN_THRESHOLD) const isExpanded = useTerminalStore((state) => state.terminalHeight > NEAR_MIN_THRESHOLD)
const { activeWorkflowId } = useWorkflowRegistry() const { activeWorkflowId } = useWorkflowRegistry()
const hasConsoleHydrated = useTerminalConsoleStore((state) => state._hasHydrated)
const workflowEntriesSelector = useCallback( const workflowEntriesSelector = useCallback(
(state: { entries: ConsoleEntry[] }) => (state: { entries: ConsoleEntry[] }) =>
state.entries.filter((entry) => entry.workflowId === activeWorkflowId), state.entries.filter((entry) => entry.workflowId === activeWorkflowId),
[activeWorkflowId] [activeWorkflowId]
) )
const entriesFromStore = useTerminalConsoleStore(useShallow(workflowEntriesSelector)) const entries = useTerminalConsoleStore(useShallow(workflowEntriesSelector))
const entries = hasConsoleHydrated ? entriesFromStore : []
const clearWorkflowConsole = useTerminalConsoleStore((state) => state.clearWorkflowConsole) const clearWorkflowConsole = useTerminalConsoleStore((state) => state.clearWorkflowConsole)
const exportConsoleCSV = useTerminalConsoleStore((state) => state.exportConsoleCSV) const exportConsoleCSV = useTerminalConsoleStore((state) => state.exportConsoleCSV)
const [selectedEntry, setSelectedEntry] = useState<ConsoleEntry | null>(null) const [selectedEntry, setSelectedEntry] = useState<ConsoleEntry | null>(null)
@@ -446,52 +462,25 @@ export function Terminal() {
}, [allWorkflowEntries]) }, [allWorkflowEntries])
/** /**
* Track color offset - increments when old executions are trimmed * Create stable execution ID to color index mapping based on order of first appearance.
* so remaining executions keep their colors. * Once an execution ID is assigned a color index, it keeps that index.
* Uses all workflow entries to maintain consistent colors regardless of active filters.
*/ */
const colorStateRef = useRef<{ executionIds: string[]; offset: number }>({ const executionIdOrderMap = useMemo(() => {
executionIds: [], const orderMap = new Map<string, number>()
offset: 0, let colorIndex = 0
})
/** // Process entries in reverse order (oldest first) since entries array is newest-first
* Compute colors for each execution ID using sequential assignment. // Use allWorkflowEntries to ensure colors remain consistent when filters change
* 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--) { for (let i = allWorkflowEntries.length - 1; i >= 0; i--) {
const execId = allWorkflowEntries[i].executionId const entry = allWorkflowEntries[i]
if (execId && !seen.has(execId)) { if (entry.executionId && !orderMap.has(entry.executionId)) {
currentIds.push(execId) orderMap.set(entry.executionId, colorIndex)
seen.add(execId) colorIndex++
} }
} }
const { executionIds: prevIds, offset: prevOffset } = colorStateRef.current return orderMap
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]) }, [allWorkflowEntries])
/** /**
@@ -1137,7 +1126,7 @@ export function Terminal() {
<PopoverScrollArea style={{ maxHeight: '140px' }}> <PopoverScrollArea style={{ maxHeight: '140px' }}>
{uniqueRunIds.map((runId, index) => { {uniqueRunIds.map((runId, index) => {
const isSelected = filters.runIds.has(runId) const isSelected = filters.runIds.has(runId)
const runIdColor = getRunIdColor(runId, executionColorMap) const runIdColor = getRunIdColor(runId, executionIdOrderMap)
return ( return (
<PopoverItem <PopoverItem
@@ -1148,7 +1137,7 @@ export function Terminal() {
> >
<span <span
className='flex-1 font-mono text-[12px]' className='flex-1 font-mono text-[12px]'
style={{ color: runIdColor || '#D2D2D2' }} style={{ color: runIdColor?.text || '#D2D2D2' }}
> >
{formatRunId(runId)} {formatRunId(runId)}
</span> </span>
@@ -1344,7 +1333,7 @@ export function Terminal() {
const statusInfo = getStatusInfo(entry.success, entry.error) const statusInfo = getStatusInfo(entry.success, entry.error)
const isSelected = selectedEntry?.id === entry.id const isSelected = selectedEntry?.id === entry.id
const BlockIcon = getBlockIcon(entry.blockType) const BlockIcon = getBlockIcon(entry.blockType)
const runIdColor = getRunIdColor(entry.executionId, executionColorMap) const runIdColor = getRunIdColor(entry.executionId, executionIdOrderMap)
return ( return (
<div <div
@@ -1394,7 +1383,7 @@ export function Terminal() {
COLUMN_BASE_CLASS, COLUMN_BASE_CLASS,
'truncate font-medium font-mono text-[12px]' 'truncate font-medium font-mono text-[12px]'
)} )}
style={{ color: runIdColor || '#D2D2D2' }} style={{ color: runIdColor?.text || '#D2D2D2' }}
> >
{formatRunId(entry.executionId)} {formatRunId(entry.executionId)}
</span> </span>
@@ -1420,7 +1409,7 @@ export function Terminal() {
ROW_TEXT_CLASS ROW_TEXT_CLASS
)} )}
> >
{formatTimeWithSeconds(new Date(entry.timestamp))} {formatTimestamp(entry.timestamp)}
</span> </span>
</div> </div>
) )

View File

@@ -356,9 +356,6 @@ const WorkflowContent = React.memo(() => {
/** Stores source node/handle info when a connection drag starts for drop-on-block detection. */ /** Stores source node/handle info when a connection drag starts for drop-on-block detection. */
const connectionSourceRef = useRef<{ nodeId: string; handleId: string } | null>(null) 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. */ /** Stores start positions for multi-node drag undo/redo recording. */
const multiNodeDragStartRef = useRef<Map<string, { x: number; y: number; parentId?: string }>>( const multiNodeDragStartRef = useRef<Map<string, { x: number; y: number; parentId?: string }>>(
new Map() new Map()
@@ -2217,8 +2214,7 @@ 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 onConnectStart = useCallback((_event: any, params: any) => {
const handleId: string | undefined = params?.handleId const handleId: string | undefined = params?.handleId
@@ -2227,7 +2223,6 @@ const WorkflowContent = React.memo(() => {
nodeId: params?.nodeId, nodeId: params?.nodeId,
handleId: params?.handleId, handleId: params?.handleId,
} }
connectionCompletedRef.current = false
}, []) }, [])
/** Handles new edge connections with container boundary validation. */ /** Handles new edge connections with container boundary validation. */
@@ -2288,7 +2283,6 @@ const WorkflowContent = React.memo(() => {
isInsideContainer: true, isInsideContainer: true,
}, },
}) })
connectionCompletedRef.current = true
return return
} }
@@ -2317,7 +2311,6 @@ const WorkflowContent = React.memo(() => {
} }
: undefined, : undefined,
}) })
connectionCompletedRef.current = true
} }
}, },
[addEdge, getNodes, blocks] [addEdge, getNodes, blocks]
@@ -2326,9 +2319,8 @@ const WorkflowContent = React.memo(() => {
/** /**
* Handles connection drag end. Detects if the edge was dropped over a block * Handles connection drag end. Detects if the edge was dropped over a block
* and automatically creates a connection to that block's target handle. * 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
* Uses connectionCompletedRef to check if onConnect already handled this connection * dropping on the block body instead of a handle).
* (ReactFlow pattern for distinguishing handle-to-handle vs handle-to-body drops).
*/ */
const onConnectEnd = useCallback( const onConnectEnd = useCallback(
(event: MouseEvent | TouchEvent) => { (event: MouseEvent | TouchEvent) => {
@@ -2340,12 +2332,6 @@ const WorkflowContent = React.memo(() => {
return 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 // Get cursor position in flow coordinates
const clientPos = 'changedTouches' in event ? event.changedTouches[0] : event const clientPos = 'changedTouches' in event ? event.changedTouches[0] : event
const flowPosition = screenToFlowPosition({ const flowPosition = screenToFlowPosition({
@@ -2356,14 +2342,25 @@ const WorkflowContent = React.memo(() => {
// Find node under cursor // Find node under cursor
const targetNode = findNodeAtPosition(flowPosition) const targetNode = findNodeAtPosition(flowPosition)
// Create connection if valid target found (handle-to-body case) // Create connection if valid target found AND edge doesn't already exist
// ReactFlow's onConnect fires first when dropping on a handle, so we check
// if that connection already exists to avoid creating duplicates.
// IMPORTANT: We must read directly from the store (not React state) because
// the store update from ReactFlow's onConnect may not have triggered a
// React re-render yet when this callback runs (typically 1-2ms later).
if (targetNode && targetNode.id !== source.nodeId) { if (targetNode && targetNode.id !== source.nodeId) {
onConnect({ const currentEdges = useWorkflowStore.getState().edges
source: source.nodeId, const edgeAlreadyExists = currentEdges.some(
sourceHandle: source.handleId, (e) => e.source === source.nodeId && e.target === targetNode.id
target: targetNode.id, )
targetHandle: 'target', if (!edgeAlreadyExists) {
}) onConnect({
source: source.nodeId,
sourceHandle: source.handleId,
target: targetNode.id,
targetHandle: 'target',
})
}
} }
connectionSourceRef.current = null connectionSourceRef.current = null

View File

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

View File

@@ -98,23 +98,6 @@ export const A2ABlock: BlockConfig<A2AResponse> = {
condition: { field: 'operation', value: 'a2a_send_message' }, condition: { field: 'operation', value: 'a2a_send_message' },
required: true, 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', id: 'taskId',
title: 'Task ID', title: 'Task ID',
@@ -225,14 +208,6 @@ export const A2ABlock: BlockConfig<A2AResponse> = {
type: 'string', type: 'string',
description: 'Context ID for conversation continuity', 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: { historyLength: {
type: 'number', type: 'number',
description: 'Number of history messages to include', description: 'Number of history messages to include',

View File

@@ -1,292 +0,0 @@
import { LangsmithIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types'
import type { LangsmithResponse } from '@/tools/langsmith/types'
export const LangsmithBlock: BlockConfig<LangsmithResponse> = {
type: 'langsmith',
name: 'LangSmith',
description: 'Forward workflow runs to LangSmith for observability',
longDescription:
'Send run data to LangSmith to trace executions, attach metadata, and monitor workflow performance.',
docsLink: 'https://docs.sim.ai/tools/langsmith',
category: 'tools',
bgColor: '#181C1E',
icon: LangsmithIcon,
authMode: AuthMode.ApiKey,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Create Run', id: 'create_run' },
{ label: 'Create Runs Batch', id: 'create_runs_batch' },
],
value: () => 'create_run',
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your LangSmith API key',
password: true,
required: true,
},
{
id: 'id',
title: 'Run ID',
type: 'short-input',
placeholder: 'Auto-generated if blank',
condition: { field: 'operation', value: 'create_run' },
},
{
id: 'name',
title: 'Name',
type: 'short-input',
placeholder: 'Run name',
required: { field: 'operation', value: 'create_run' },
condition: { field: 'operation', value: 'create_run' },
},
{
id: 'run_type',
title: 'Run Type',
type: 'dropdown',
options: [
{ label: 'Chain', id: 'chain' },
{ label: 'Tool', id: 'tool' },
{ label: 'LLM', id: 'llm' },
{ label: 'Retriever', id: 'retriever' },
{ label: 'Embedding', id: 'embedding' },
{ label: 'Prompt', id: 'prompt' },
{ label: 'Parser', id: 'parser' },
],
value: () => 'chain',
required: { field: 'operation', value: 'create_run' },
condition: { field: 'operation', value: 'create_run' },
},
{
id: 'start_time',
title: 'Start Time',
type: 'short-input',
placeholder: '2025-01-01T12:00:00Z',
condition: { field: 'operation', value: 'create_run' },
value: () => new Date().toISOString(),
},
{
id: 'end_time',
title: 'End Time',
type: 'short-input',
placeholder: '2025-01-01T12:00:30Z',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'inputs',
title: 'Inputs',
type: 'code',
placeholder: '{"input":"value"}',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'outputs',
title: 'Outputs',
type: 'code',
placeholder: '{"output":"value"}',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'extra',
title: 'Metadata',
type: 'code',
placeholder: '{"ls_model":"gpt-4"}',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'tags',
title: 'Tags',
type: 'code',
placeholder: '["production","workflow"]',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'parent_run_id',
title: 'Parent Run ID',
type: 'short-input',
placeholder: 'Parent run identifier',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'trace_id',
title: 'Trace ID',
type: 'short-input',
placeholder: 'Auto-generated if blank',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'session_id',
title: 'Session ID',
type: 'short-input',
placeholder: 'Session identifier',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'session_name',
title: 'Session Name',
type: 'short-input',
placeholder: 'Session name',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'status',
title: 'Status',
type: 'short-input',
placeholder: 'success',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'error',
title: 'Error',
type: 'long-input',
placeholder: 'Error message',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'dotted_order',
title: 'Dotted Order',
type: 'short-input',
placeholder: 'Defaults to <YYYYMMDDTHHMMSSffffff>Z<id>',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'events',
title: 'Events',
type: 'code',
placeholder: '[{"event":"token","value":1}]',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'post',
title: 'Post Runs',
type: 'code',
placeholder: '[{"id":"...","name":"...","run_type":"chain","start_time":"..."}]',
condition: { field: 'operation', value: 'create_runs_batch' },
},
{
id: 'patch',
title: 'Patch Runs',
type: 'code',
placeholder: '[{"id":"...","name":"...","run_type":"chain","start_time":"..."}]',
condition: { field: 'operation', value: 'create_runs_batch' },
mode: 'advanced',
},
],
tools: {
access: ['langsmith_create_run', 'langsmith_create_runs_batch'],
config: {
tool: (params) => {
switch (params.operation) {
case 'create_runs_batch':
return 'langsmith_create_runs_batch'
case 'create_run':
default:
return 'langsmith_create_run'
}
},
params: (params) => {
const parseJsonValue = (value: unknown, label: string) => {
if (value === undefined || value === null || value === '') {
return undefined
}
if (typeof value === 'string') {
try {
return JSON.parse(value)
} catch (error) {
throw new Error(
`Invalid JSON for ${label}: ${error instanceof Error ? error.message : String(error)}`
)
}
}
return value
}
if (params.operation === 'create_runs_batch') {
const post = parseJsonValue(params.post, 'post runs')
const patch = parseJsonValue(params.patch, 'patch runs')
if (!post && !patch) {
throw new Error('Provide at least one of post or patch runs')
}
return {
apiKey: params.apiKey,
post,
patch,
}
}
return {
apiKey: params.apiKey,
id: params.id,
name: params.name,
run_type: params.run_type,
start_time: params.start_time,
end_time: params.end_time,
inputs: parseJsonValue(params.inputs, 'inputs'),
outputs: parseJsonValue(params.outputs, 'outputs'),
extra: parseJsonValue(params.extra, 'metadata'),
tags: parseJsonValue(params.tags, 'tags'),
parent_run_id: params.parent_run_id,
trace_id: params.trace_id,
session_id: params.session_id,
session_name: params.session_name,
status: params.status,
error: params.error,
dotted_order: params.dotted_order,
events: parseJsonValue(params.events, 'events'),
}
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
apiKey: { type: 'string', description: 'LangSmith API key' },
id: { type: 'string', description: 'Run identifier' },
name: { type: 'string', description: 'Run name' },
run_type: { type: 'string', description: 'Run type' },
start_time: { type: 'string', description: 'Run start time (ISO)' },
end_time: { type: 'string', description: 'Run end time (ISO)' },
inputs: { type: 'json', description: 'Run inputs payload' },
outputs: { type: 'json', description: 'Run outputs payload' },
extra: { type: 'json', description: 'Additional metadata (extra)' },
tags: { type: 'json', description: 'Tags array' },
parent_run_id: { type: 'string', description: 'Parent run ID' },
trace_id: { type: 'string', description: 'Trace ID' },
session_id: { type: 'string', description: 'Session ID' },
session_name: { type: 'string', description: 'Session name' },
status: { type: 'string', description: 'Run status' },
error: { type: 'string', description: 'Error message' },
dotted_order: { type: 'string', description: 'Dotted order string' },
events: { type: 'json', description: 'Events array' },
post: { type: 'json', description: 'Runs to ingest in batch' },
patch: { type: 'json', description: 'Runs to update in batch' },
},
outputs: {
accepted: { type: 'boolean', description: 'Whether ingestion was accepted' },
runId: { type: 'string', description: 'Run ID for single run' },
runIds: { type: 'array', description: 'Run IDs for batch ingest' },
message: { type: 'string', description: 'LangSmith response message' },
messages: { type: 'array', description: 'Per-run response messages' },
},
}

View File

@@ -172,7 +172,7 @@ export const ScheduleBlock: BlockConfig = {
{ label: 'Melbourne (UTC+10)', id: 'Australia/Melbourne' }, { label: 'Melbourne (UTC+10)', id: 'Australia/Melbourne' },
{ label: 'Auckland (UTC+12)', id: 'Pacific/Auckland' }, { label: 'Auckland (UTC+12)', id: 'Pacific/Auckland' },
], ],
value: () => Intl.DateTimeFormat().resolvedOptions().timeZone, value: () => 'UTC',
required: false, required: false,
mode: 'trigger', mode: 'trigger',
condition: { field: 'scheduleType', value: ['minutes', 'hourly'], not: true }, condition: { field: 'scheduleType', value: ['minutes', 'hourly'], not: true },

View File

@@ -26,8 +26,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
{ label: 'Send Message', id: 'send' }, { label: 'Send Message', id: 'send' },
{ label: 'Create Canvas', id: 'canvas' }, { label: 'Create Canvas', id: 'canvas' },
{ label: 'Read Messages', id: 'read' }, { 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 Channels', id: 'list_channels' },
{ label: 'List Channel Members', id: 'list_members' }, { label: 'List Channel Members', id: 'list_members' },
{ label: 'List Users', id: 'list_users' }, { label: 'List Users', id: 'list_users' },
@@ -318,68 +316,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
}, },
required: true, 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', id: 'oldest',
title: 'Oldest Timestamp', title: 'Oldest Timestamp',
@@ -494,8 +430,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
'slack_message', 'slack_message',
'slack_canvas', 'slack_canvas',
'slack_message_reader', 'slack_message_reader',
'slack_get_message',
'slack_get_thread',
'slack_list_channels', 'slack_list_channels',
'slack_list_members', 'slack_list_members',
'slack_list_users', 'slack_list_users',
@@ -514,10 +448,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
return 'slack_canvas' return 'slack_canvas'
case 'read': case 'read':
return 'slack_message_reader' return 'slack_message_reader'
case 'get_message':
return 'slack_get_message'
case 'get_thread':
return 'slack_get_thread'
case 'list_channels': case 'list_channels':
return 'slack_list_channels' return 'slack_list_channels'
case 'list_members': case 'list_members':
@@ -568,9 +498,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
includeDeleted, includeDeleted,
userLimit, userLimit,
userId, userId,
getMessageTimestamp,
getThreadTimestamp,
threadLimit,
...rest ...rest
} = params } = params
@@ -647,27 +574,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
break 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': { case 'list_channels': {
baseParams.includePrivate = includePrivate !== 'false' baseParams.includePrivate = includePrivate !== 'false'
baseParams.excludeArchived = true baseParams.excludeArchived = true
@@ -773,14 +679,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
userLimit: { type: 'string', description: 'Maximum number of users to return' }, userLimit: { type: 'string', description: 'Maximum number of users to return' },
// Get User inputs // Get User inputs
userId: { type: 'string', description: 'User ID to look up' }, 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: { outputs: {
// slack_message outputs (send operation) // slack_message outputs (send operation)
@@ -808,24 +706,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
'Array of message objects with comprehensive properties: text, user, timestamp, reactions, threads, files, attachments, blocks, stars, pins, and edit history', '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) // slack_list_channels outputs (list_channels operation)
channels: { channels: {
type: 'json', type: 'json',

View File

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

View File

@@ -61,7 +61,6 @@ import { JiraBlock } from '@/blocks/blocks/jira'
import { JiraServiceManagementBlock } from '@/blocks/blocks/jira_service_management' import { JiraServiceManagementBlock } from '@/blocks/blocks/jira_service_management'
import { KalshiBlock } from '@/blocks/blocks/kalshi' import { KalshiBlock } from '@/blocks/blocks/kalshi'
import { KnowledgeBlock } from '@/blocks/blocks/knowledge' import { KnowledgeBlock } from '@/blocks/blocks/knowledge'
import { LangsmithBlock } from '@/blocks/blocks/langsmith'
import { LemlistBlock } from '@/blocks/blocks/lemlist' import { LemlistBlock } from '@/blocks/blocks/lemlist'
import { LinearBlock } from '@/blocks/blocks/linear' import { LinearBlock } from '@/blocks/blocks/linear'
import { LinkedInBlock } from '@/blocks/blocks/linkedin' import { LinkedInBlock } from '@/blocks/blocks/linkedin'
@@ -122,7 +121,6 @@ import { SupabaseBlock } from '@/blocks/blocks/supabase'
import { TavilyBlock } from '@/blocks/blocks/tavily' import { TavilyBlock } from '@/blocks/blocks/tavily'
import { TelegramBlock } from '@/blocks/blocks/telegram' import { TelegramBlock } from '@/blocks/blocks/telegram'
import { ThinkingBlock } from '@/blocks/blocks/thinking' import { ThinkingBlock } from '@/blocks/blocks/thinking'
import { TinybirdBlock } from '@/blocks/blocks/tinybird'
import { TranslateBlock } from '@/blocks/blocks/translate' import { TranslateBlock } from '@/blocks/blocks/translate'
import { TrelloBlock } from '@/blocks/blocks/trello' import { TrelloBlock } from '@/blocks/blocks/trello'
import { TtsBlock } from '@/blocks/blocks/tts' import { TtsBlock } from '@/blocks/blocks/tts'
@@ -218,7 +216,6 @@ export const registry: Record<string, BlockConfig> = {
jira_service_management: JiraServiceManagementBlock, jira_service_management: JiraServiceManagementBlock,
kalshi: KalshiBlock, kalshi: KalshiBlock,
knowledge: KnowledgeBlock, knowledge: KnowledgeBlock,
langsmith: LangsmithBlock,
lemlist: LemlistBlock, lemlist: LemlistBlock,
linear: LinearBlock, linear: LinearBlock,
linkedin: LinkedInBlock, linkedin: LinkedInBlock,
@@ -284,7 +281,6 @@ export const registry: Record<string, BlockConfig> = {
tavily: TavilyBlock, tavily: TavilyBlock,
telegram: TelegramBlock, telegram: TelegramBlock,
thinking: ThinkingBlock, thinking: ThinkingBlock,
tinybird: TinybirdBlock,
translate: TranslateBlock, translate: TranslateBlock,
trello: TrelloBlock, trello: TrelloBlock,
twilio_sms: TwilioSMSBlock, twilio_sms: TwilioSMSBlock,
@@ -317,26 +313,6 @@ export const getBlock = (type: string): BlockConfig | undefined => {
return registry[normalized] 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 => { export const getBlockByToolName = (toolName: string): BlockConfig | undefined => {
return Object.values(registry).find((block) => block.tools?.access?.includes(toolName)) return Object.values(registry).find((block) => block.tools?.access?.includes(toolName))
} }

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -22,7 +22,7 @@ import { useUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { filterNewEdges, mergeSubblockState, normalizeName } from '@/stores/workflows/utils' import { mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types' import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
@@ -242,10 +242,7 @@ export function useCollaborativeWorkflow() {
case EDGES_OPERATIONS.BATCH_ADD_EDGES: { case EDGES_OPERATIONS.BATCH_ADD_EDGES: {
const { edges } = payload const { edges } = payload
if (Array.isArray(edges) && edges.length > 0) { if (Array.isArray(edges) && edges.length > 0) {
const newEdges = filterNewEdges(edges, workflowStore.edges) workflowStore.batchAddEdges(edges)
if (newEdges.length > 0) {
workflowStore.batchAddEdges(newEdges)
}
} }
break break
} }
@@ -979,9 +976,6 @@ export function useCollaborativeWorkflow() {
if (edges.length === 0) return false if (edges.length === 0) return false
const newEdges = filterNewEdges(edges, workflowStore.edges)
if (newEdges.length === 0) return false
const operationId = crypto.randomUUID() const operationId = crypto.randomUUID()
addToQueue({ addToQueue({
@@ -989,16 +983,16 @@ export function useCollaborativeWorkflow() {
operation: { operation: {
operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, operation: EDGES_OPERATIONS.BATCH_ADD_EDGES,
target: OPERATION_TARGETS.EDGES, target: OPERATION_TARGETS.EDGES,
payload: { edges: newEdges }, payload: { edges },
}, },
workflowId: activeWorkflowId || '', workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown', userId: session?.user?.id || 'unknown',
}) })
workflowStore.batchAddEdges(newEdges) workflowStore.batchAddEdges(edges)
if (!options?.skipUndoRedo) { if (!options?.skipUndoRedo) {
newEdges.forEach((edge) => undoRedo.recordAddEdge(edge.id)) edges.forEach((edge) => undoRedo.recordAddEdge(edge.id))
} }
return true return true

View File

@@ -36,10 +36,9 @@ class ApiKeyInterceptor implements CallInterceptor {
/** /**
* Create an A2A client from an agent URL with optional API key authentication * Create an A2A client from an agent URL with optional API key authentication
* *
* Supports both standard A2A agents (agent card at /.well-known/agent.json) * The agent URL should be the full endpoint URL (e.g., /api/a2a/serve/{agentId}).
* and Sim Studio agents (agent card at root URL via GET). * 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.
* Tries standard path first, falls back to root URL for compatibility.
*/ */
export async function createA2AClient(agentUrl: string, apiKey?: string): Promise<Client> { export async function createA2AClient(agentUrl: string, apiKey?: string): Promise<Client> {
const factoryOptions = apiKey const factoryOptions = apiKey
@@ -50,18 +49,6 @@ export async function createA2AClient(agentUrl: string, apiKey?: string): Promis
}) })
: ClientFactoryOptions.default : ClientFactoryOptions.default
const factory = new ClientFactory(factoryOptions) 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, '') return factory.createFromUrl(agentUrl, '')
} }

View File

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

View File

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

View File

@@ -99,7 +99,6 @@ export interface SendMessageRequest {
workflowId?: string workflowId?: string
executionId?: string executionId?: string
}> }>
commands?: string[]
} }
/** /**

View File

@@ -10,7 +10,6 @@ import {
GetBlockConfigInput, GetBlockConfigInput,
GetBlockConfigResult, GetBlockConfigResult,
} from '@/lib/copilot/tools/shared/schemas' } from '@/lib/copilot/tools/shared/schemas'
import { getBlock } from '@/blocks/registry'
interface GetBlockConfigArgs { interface GetBlockConfigArgs {
blockType: string blockType: string
@@ -40,9 +39,7 @@ export class GetBlockConfigClientTool extends BaseClientTool {
}, },
getDynamicText: (params, state) => { getDynamicText: (params, state) => {
if (params?.blockType && typeof params.blockType === 'string') { if (params?.blockType && typeof params.blockType === 'string') {
// Look up the block config to get the human-readable name const blockName = params.blockType.replace(/_/g, ' ')
const blockConfig = getBlock(params.blockType)
const blockName = (blockConfig?.name ?? params.blockType.replace(/_/g, ' ')).toLowerCase()
const opSuffix = params.operation ? ` (${params.operation})` : '' const opSuffix = params.operation ? ` (${params.operation})` : ''
switch (state) { switch (state) {

View File

@@ -10,7 +10,6 @@ import {
GetBlockOptionsInput, GetBlockOptionsInput,
GetBlockOptionsResult, GetBlockOptionsResult,
} from '@/lib/copilot/tools/shared/schemas' } from '@/lib/copilot/tools/shared/schemas'
import { getBlock } from '@/blocks/registry'
interface GetBlockOptionsArgs { interface GetBlockOptionsArgs {
blockId: string blockId: string
@@ -38,9 +37,7 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
}, },
getDynamicText: (params, state) => { getDynamicText: (params, state) => {
if (params?.blockId && typeof params.blockId === 'string') { if (params?.blockId && typeof params.blockId === 'string') {
// Look up the block config to get the human-readable name const blockName = params.blockId.replace(/_/g, ' ')
const blockConfig = getBlock(params.blockId)
const blockName = (blockConfig?.name ?? params.blockId.replace(/_/g, ' ')).toLowerCase()
switch (state) { switch (state) {
case ClientToolCallState.success: case ClientToolCallState.success:

View File

@@ -18,7 +18,6 @@ import './other/make-api-request'
import './other/plan' import './other/plan'
import './other/research' import './other/research'
import './other/sleep' import './other/sleep'
import './other/superagent'
import './other/test' import './other/test'
import './other/tour' import './other/tour'
import './other/workflow' import './other/workflow'

View File

@@ -1,53 +0,0 @@
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class CrawlWebsiteClientTool extends BaseClientTool {
static readonly id = 'crawl_website'
constructor(toolCallId: string) {
super(toolCallId, CrawlWebsiteClientTool.id, CrawlWebsiteClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Crawling website', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Crawling website', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Crawling website', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Crawled website', icon: Globe },
[ClientToolCallState.error]: { text: 'Failed to crawl website', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted crawling website', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped crawling website', icon: MinusCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.url && typeof params.url === 'string') {
const url = params.url
const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url
switch (state) {
case ClientToolCallState.success:
return `Crawled ${truncated}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Crawling ${truncated}`
case ClientToolCallState.error:
return `Failed to crawl ${truncated}`
case ClientToolCallState.aborted:
return `Aborted crawling ${truncated}`
case ClientToolCallState.rejected:
return `Skipped crawling ${truncated}`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,54 +0,0 @@
import { FileText, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class GetPageContentsClientTool extends BaseClientTool {
static readonly id = 'get_page_contents'
constructor(toolCallId: string) {
super(toolCallId, GetPageContentsClientTool.id, GetPageContentsClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Getting page contents', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting page contents', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Getting page contents', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Retrieved page contents', icon: FileText },
[ClientToolCallState.error]: { text: 'Failed to get page contents', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting page contents', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped getting page contents', icon: MinusCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.urls && Array.isArray(params.urls) && params.urls.length > 0) {
const firstUrl = String(params.urls[0])
const truncated = firstUrl.length > 40 ? `${firstUrl.slice(0, 40)}...` : firstUrl
const count = params.urls.length
switch (state) {
case ClientToolCallState.success:
return count > 1 ? `Retrieved ${count} pages` : `Retrieved ${truncated}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return count > 1 ? `Getting ${count} pages` : `Getting ${truncated}`
case ClientToolCallState.error:
return count > 1 ? `Failed to get ${count} pages` : `Failed to get ${truncated}`
case ClientToolCallState.aborted:
return count > 1 ? `Aborted getting ${count} pages` : `Aborted getting ${truncated}`
case ClientToolCallState.rejected:
return count > 1 ? `Skipped getting ${count} pages` : `Skipped getting ${truncated}`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,53 +0,0 @@
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class ScrapePageClientTool extends BaseClientTool {
static readonly id = 'scrape_page'
constructor(toolCallId: string) {
super(toolCallId, ScrapePageClientTool.id, ScrapePageClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Scraping page', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Scraping page', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Scraping page', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Scraped page', icon: Globe },
[ClientToolCallState.error]: { text: 'Failed to scrape page', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted scraping page', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped scraping page', icon: MinusCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.url && typeof params.url === 'string') {
const url = params.url
const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url
switch (state) {
case ClientToolCallState.success:
return `Scraped ${truncated}`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Scraping ${truncated}`
case ClientToolCallState.error:
return `Failed to scrape ${truncated}`
case ClientToolCallState.aborted:
return `Aborted scraping ${truncated}`
case ClientToolCallState.rejected:
return `Skipped scraping ${truncated}`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -1,9 +1,19 @@
import { createLogger } from '@sim/logger'
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react' import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
import { import {
BaseClientTool, BaseClientTool,
type BaseClientToolMetadata, type BaseClientToolMetadata,
ClientToolCallState, ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool' } from '@/lib/copilot/tools/client/base-tool'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
interface SearchOnlineArgs {
query: string
num?: number
type?: string
gl?: string
hl?: string
}
export class SearchOnlineClientTool extends BaseClientTool { export class SearchOnlineClientTool extends BaseClientTool {
static readonly id = 'search_online' static readonly id = 'search_online'
@@ -22,7 +32,6 @@ export class SearchOnlineClientTool extends BaseClientTool {
[ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle }, [ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle }, [ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle },
}, },
interrupt: undefined,
getDynamicText: (params, state) => { getDynamicText: (params, state) => {
if (params?.query && typeof params.query === 'string') { if (params?.query && typeof params.query === 'string') {
const query = params.query const query = params.query
@@ -47,7 +56,28 @@ export class SearchOnlineClientTool extends BaseClientTool {
}, },
} }
async execute(): Promise<void> { async execute(args?: SearchOnlineArgs): Promise<void> {
return const logger = createLogger('SearchOnlineClientTool')
try {
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolName: 'search_online', payload: args || {} }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Server error (${res.status})`)
}
const json = await res.json()
const parsed = ExecuteResponseSuccessSchema.parse(json)
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Online search complete', parsed.result)
this.setState(ClientToolCallState.success)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Search failed')
}
} }
} }

View File

@@ -1,56 +0,0 @@
import { Loader2, Sparkles, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface SuperagentArgs {
instruction: string
}
/**
* Superagent tool that spawns a powerful subagent for complex tasks.
* This tool auto-executes and the actual work is done by the superagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class SuperagentClientTool extends BaseClientTool {
static readonly id = 'superagent'
constructor(toolCallId: string) {
super(toolCallId, SuperagentClientTool.id, SuperagentClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Superagent working', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Superagent working', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Superagent working', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Superagent completed', icon: Sparkles },
[ClientToolCallState.error]: { text: 'Superagent failed', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Superagent skipped', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Superagent aborted', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Superagent working',
completedLabel: 'Superagent completed',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the superagent tool.
* This just marks the tool as executing - the actual work is done server-side
* by the superagent, and its output is streamed as subagent events.
*/
async execute(_args?: SuperagentArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(SuperagentClientTool.id, SuperagentClientTool.metadata.uiConfig!)

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
import { createLogger } from '@sim/logger'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils' import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils'
import { import {
classifyStartBlockType, classifyStartBlockType,
@@ -13,11 +12,8 @@ import {
} from '@/lib/workflows/types' } from '@/lib/workflows/types'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import type { BlockConfig, OutputCondition, OutputFieldDefinition } from '@/blocks/types' import type { BlockConfig, OutputCondition, OutputFieldDefinition } from '@/blocks/types'
import { getTool } from '@/tools/utils'
import { getTrigger, isTriggerValid } from '@/triggers' import { getTrigger, isTriggerValid } from '@/triggers'
const logger = createLogger('BlockOutputs')
type OutputDefinition = Record<string, OutputFieldDefinition> type OutputDefinition = Record<string, OutputFieldDefinition>
interface SubBlockWithValue { interface SubBlockWithValue {
@@ -439,167 +435,3 @@ export function getBlockOutputType(
const value = traverseOutputPath(outputs, pathParts) const value = traverseOutputPath(outputs, pathParts)
return extractType(value) return extractType(value)
} }
/**
* Recursively generates all output paths from an outputs schema.
*
* @param outputs - The outputs schema object
* @param prefix - Current path prefix for recursion
* @returns Array of dot-separated paths to all output fields
*/
function generateOutputPaths(outputs: Record<string, any>, prefix = ''): string[] {
const paths: string[] = []
for (const [key, value] of Object.entries(outputs)) {
const currentPath = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
paths.push(currentPath)
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
const hasNestedProperties =
((value.type === 'object' || value.type === 'json') && value.properties) ||
(value.type === 'array' && value.items?.properties) ||
(value.type === 'array' &&
value.items &&
typeof value.items === 'object' &&
!('type' in value.items))
if (!hasNestedProperties) {
paths.push(currentPath)
}
if ((value.type === 'object' || value.type === 'json') && value.properties) {
paths.push(...generateOutputPaths(value.properties, currentPath))
} else if (value.type === 'array' && value.items?.properties) {
paths.push(...generateOutputPaths(value.items.properties, currentPath))
} else if (
value.type === 'array' &&
value.items &&
typeof value.items === 'object' &&
!('type' in value.items)
) {
paths.push(...generateOutputPaths(value.items, currentPath))
}
} else {
const subPaths = generateOutputPaths(value, currentPath)
paths.push(...subPaths)
}
} else {
paths.push(currentPath)
}
}
return paths
}
/**
* Recursively generates all output paths with their types from an outputs schema.
*
* @param outputs - The outputs schema object
* @param prefix - Current path prefix for recursion
* @returns Array of objects containing path and type for each output field
*/
function generateOutputPathsWithTypes(
outputs: Record<string, any>,
prefix = ''
): Array<{ path: string; type: string }> {
const paths: Array<{ path: string; type: string }> = []
for (const [key, value] of Object.entries(outputs)) {
const currentPath = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
paths.push({ path: currentPath, type: value })
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
if (value.type === 'array' && value.items?.properties) {
paths.push({ path: currentPath, type: 'array' })
const subPaths = generateOutputPathsWithTypes(value.items.properties, currentPath)
paths.push(...subPaths)
} else if ((value.type === 'object' || value.type === 'json') && value.properties) {
paths.push({ path: currentPath, type: value.type })
const subPaths = generateOutputPathsWithTypes(value.properties, currentPath)
paths.push(...subPaths)
} else {
paths.push({ path: currentPath, type: value.type })
}
} else {
const subPaths = generateOutputPathsWithTypes(value, currentPath)
paths.push(...subPaths)
}
} else {
paths.push({ path: currentPath, type: 'any' })
}
}
return paths
}
/**
* Gets the tool outputs for a block operation.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @returns Outputs schema for the tool, or empty object on error
*/
export function getToolOutputs(blockConfig: BlockConfig, operation: string): Record<string, any> {
if (!blockConfig?.tools?.config?.tool) return {}
try {
const toolId = blockConfig.tools.config.tool({ operation })
if (!toolId) return {}
const toolConfig = getTool(toolId)
if (!toolConfig?.outputs) return {}
return toolConfig.outputs
} catch (error) {
logger.warn('Failed to get tool outputs for operation', { operation, error })
return {}
}
}
/**
* Generates output paths for a tool-based block.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @returns Array of output paths for the tool, or empty array on error
*/
export function getToolOutputPaths(blockConfig: BlockConfig, operation: string): string[] {
const outputs = getToolOutputs(blockConfig, operation)
if (!outputs || Object.keys(outputs).length === 0) return []
return generateOutputPaths(outputs)
}
/**
* Generates output paths from a schema definition.
*
* @param outputs - The outputs schema object
* @returns Array of dot-separated paths to all output fields
*/
export function getOutputPathsFromSchema(outputs: Record<string, any>): string[] {
return generateOutputPaths(outputs)
}
/**
* Gets the output type for a specific path in a tool's outputs.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @param path - The dot-separated path to the output field
* @returns The type of the output field, or 'any' if not found
*/
export function getToolOutputType(
blockConfig: BlockConfig,
operation: string,
path: string
): string {
const outputs = getToolOutputs(blockConfig, operation)
if (!outputs || Object.keys(outputs).length === 0) return 'any'
const pathsWithTypes = generateOutputPathsWithTypes(outputs)
const matchingPath = pathsWithTypes.find((p) => p.path === path)
return matchingPath?.type || 'any'
}

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ import postgres from 'postgres'
import { env } from '@/lib/core/config/env' import { env } from '@/lib/core/config/env'
import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { mergeSubBlockValues } from '@/lib/workflows/subblocks'
import { import {
BLOCK_OPERATIONS, BLOCK_OPERATIONS,
BLOCKS_OPERATIONS, BLOCKS_OPERATIONS,
@@ -456,7 +455,7 @@ async function handleBlocksOperationTx(
} }
case BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS: { case BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS: {
const { blocks, edges, loops, parallels, subBlockValues } = payload const { blocks, edges, loops, parallels } = payload
logger.info(`Batch adding blocks to workflow ${workflowId}`, { logger.info(`Batch adding blocks to workflow ${workflowId}`, {
blockCount: blocks?.length || 0, blockCount: blocks?.length || 0,
@@ -466,30 +465,22 @@ async function handleBlocksOperationTx(
}) })
if (blocks && blocks.length > 0) { if (blocks && blocks.length > 0) {
const blockValues = blocks.map((block: Record<string, unknown>) => { const blockValues = blocks.map((block: Record<string, unknown>) => ({
const blockId = block.id as string id: block.id as string,
const mergedSubBlocks = mergeSubBlockValues( workflowId,
block.subBlocks as Record<string, unknown>, type: block.type as string,
subBlockValues?.[blockId] name: block.name as string,
) positionX: (block.position as { x: number; y: number }).x,
positionY: (block.position as { x: number; y: number }).y,
return { data: (block.data as Record<string, unknown>) || {},
id: blockId, subBlocks: (block.subBlocks as Record<string, unknown>) || {},
workflowId, outputs: (block.outputs as Record<string, unknown>) || {},
type: block.type as string, enabled: (block.enabled as boolean) ?? true,
name: block.name as string, horizontalHandles: (block.horizontalHandles as boolean) ?? true,
positionX: (block.position as { x: number; y: number }).x, advancedMode: (block.advancedMode as boolean) ?? false,
positionY: (block.position as { x: number; y: number }).y, triggerMode: (block.triggerMode as boolean) ?? false,
data: (block.data as Record<string, unknown>) || {}, height: (block.height as number) || 0,
subBlocks: mergedSubBlocks, }))
outputs: (block.outputs as Record<string, unknown>) || {},
enabled: (block.enabled as boolean) ?? true,
horizontalHandles: (block.horizontalHandles as boolean) ?? true,
advancedMode: (block.advancedMode as boolean) ?? false,
triggerMode: (block.triggerMode as boolean) ?? false,
height: (block.height as number) || 0,
}
})
await tx.insert(workflowBlocks).values(blockValues) await tx.insert(workflowBlocks).values(blockValues)

View File

@@ -27,13 +27,11 @@ import {
import { NavigateUIClientTool } from '@/lib/copilot/tools/client/navigation/navigate-ui' import { NavigateUIClientTool } from '@/lib/copilot/tools/client/navigation/navigate-ui'
import { AuthClientTool } from '@/lib/copilot/tools/client/other/auth' import { AuthClientTool } from '@/lib/copilot/tools/client/other/auth'
import { CheckoffTodoClientTool } from '@/lib/copilot/tools/client/other/checkoff-todo' import { CheckoffTodoClientTool } from '@/lib/copilot/tools/client/other/checkoff-todo'
import { CrawlWebsiteClientTool } from '@/lib/copilot/tools/client/other/crawl-website'
import { CustomToolClientTool } from '@/lib/copilot/tools/client/other/custom-tool' import { CustomToolClientTool } from '@/lib/copilot/tools/client/other/custom-tool'
import { DebugClientTool } from '@/lib/copilot/tools/client/other/debug' import { DebugClientTool } from '@/lib/copilot/tools/client/other/debug'
import { DeployClientTool } from '@/lib/copilot/tools/client/other/deploy' import { DeployClientTool } from '@/lib/copilot/tools/client/other/deploy'
import { EditClientTool } from '@/lib/copilot/tools/client/other/edit' import { EditClientTool } from '@/lib/copilot/tools/client/other/edit'
import { EvaluateClientTool } from '@/lib/copilot/tools/client/other/evaluate' import { EvaluateClientTool } from '@/lib/copilot/tools/client/other/evaluate'
import { GetPageContentsClientTool } from '@/lib/copilot/tools/client/other/get-page-contents'
import { InfoClientTool } from '@/lib/copilot/tools/client/other/info' import { InfoClientTool } from '@/lib/copilot/tools/client/other/info'
import { KnowledgeClientTool } from '@/lib/copilot/tools/client/other/knowledge' import { KnowledgeClientTool } from '@/lib/copilot/tools/client/other/knowledge'
import { MakeApiRequestClientTool } from '@/lib/copilot/tools/client/other/make-api-request' import { MakeApiRequestClientTool } from '@/lib/copilot/tools/client/other/make-api-request'
@@ -42,7 +40,6 @@ import { OAuthRequestAccessClientTool } from '@/lib/copilot/tools/client/other/o
import { PlanClientTool } from '@/lib/copilot/tools/client/other/plan' import { PlanClientTool } from '@/lib/copilot/tools/client/other/plan'
import { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/remember-debug' import { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/remember-debug'
import { ResearchClientTool } from '@/lib/copilot/tools/client/other/research' import { ResearchClientTool } from '@/lib/copilot/tools/client/other/research'
import { ScrapePageClientTool } from '@/lib/copilot/tools/client/other/scrape-page'
import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation' import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation'
import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors' import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors'
import { SearchLibraryDocsClientTool } from '@/lib/copilot/tools/client/other/search-library-docs' import { SearchLibraryDocsClientTool } from '@/lib/copilot/tools/client/other/search-library-docs'
@@ -123,9 +120,6 @@ const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
search_library_docs: (id) => new SearchLibraryDocsClientTool(id), search_library_docs: (id) => new SearchLibraryDocsClientTool(id),
search_patterns: (id) => new SearchPatternsClientTool(id), search_patterns: (id) => new SearchPatternsClientTool(id),
search_errors: (id) => new SearchErrorsClientTool(id), search_errors: (id) => new SearchErrorsClientTool(id),
scrape_page: (id) => new ScrapePageClientTool(id),
get_page_contents: (id) => new GetPageContentsClientTool(id),
crawl_website: (id) => new CrawlWebsiteClientTool(id),
remember_debug: (id) => new RememberDebugClientTool(id), remember_debug: (id) => new RememberDebugClientTool(id),
set_environment_variables: (id) => new SetEnvironmentVariablesClientTool(id), set_environment_variables: (id) => new SetEnvironmentVariablesClientTool(id),
get_credentials: (id) => new GetCredentialsClientTool(id), get_credentials: (id) => new GetCredentialsClientTool(id),
@@ -185,9 +179,6 @@ export const CLASS_TOOL_METADATA: Record<string, BaseClientToolMetadata | undefi
search_library_docs: (SearchLibraryDocsClientTool as any)?.metadata, search_library_docs: (SearchLibraryDocsClientTool as any)?.metadata,
search_patterns: (SearchPatternsClientTool as any)?.metadata, search_patterns: (SearchPatternsClientTool as any)?.metadata,
search_errors: (SearchErrorsClientTool as any)?.metadata, search_errors: (SearchErrorsClientTool as any)?.metadata,
scrape_page: (ScrapePageClientTool as any)?.metadata,
get_page_contents: (GetPageContentsClientTool as any)?.metadata,
crawl_website: (CrawlWebsiteClientTool as any)?.metadata,
remember_debug: (RememberDebugClientTool as any)?.metadata, remember_debug: (RememberDebugClientTool as any)?.metadata,
set_environment_variables: (SetEnvironmentVariablesClientTool as any)?.metadata, set_environment_variables: (SetEnvironmentVariablesClientTool as any)?.metadata,
get_credentials: (GetCredentialsClientTool as any)?.metadata, get_credentials: (GetCredentialsClientTool as any)?.metadata,
@@ -1223,20 +1214,30 @@ const sseHandlers: Record<string, SSEHandler> = {
} }
} catch {} } catch {}
// Integration tools: Stay in pending state until user confirms via buttons // Integration tools: Check if auto-allowed, otherwise wait for user confirmation
// This handles tools like google_calendar_*, exa_*, gmail_read, etc. that aren't in the client registry // This handles tools like google_calendar_*, exa_*, etc. that aren't in the client registry
// Only relevant if mode is 'build' (agent) // Only relevant if mode is 'build' (agent)
const { mode, workflowId } = get() const { mode, workflowId, autoAllowedTools } = get()
if (mode === 'build' && workflowId) { if (mode === 'build' && workflowId) {
// Check if tool was NOT found in client registry // Check if tool was NOT found in client registry (def is undefined from above)
const def = name ? getTool(name) : undefined const def = name ? getTool(name) : undefined
const inst = getClientTool(id) as any const inst = getClientTool(id) as any
if (!def && !inst && name) { if (!def && !inst && name) {
// Integration tools stay in pending state until user confirms // Check if this tool is auto-allowed
logger.info('[build mode] Integration tool awaiting user confirmation', { if (autoAllowedTools.includes(name)) {
id, logger.info('[build mode] Integration tool auto-allowed, executing', { id, name })
name,
}) // Auto-execute the tool
setTimeout(() => {
get().executeIntegrationTool(id)
}, 0)
} else {
// Integration tools stay in pending state until user confirms
logger.info('[build mode] Integration tool awaiting user confirmation', {
id,
name,
})
}
} }
} }
}, },
@@ -1853,7 +1854,7 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
updateToolCallWithSubAgentData(context, get, set, parentToolCallId) updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
// Execute client tools in parallel (non-blocking) - same pattern as main tool_call handler // Execute client tools (same logic as main tool_call handler)
try { try {
const def = getTool(name) const def = getTool(name)
if (def) { if (def) {
@@ -1862,33 +1863,29 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
? !!def.hasInterrupt(args || {}) ? !!def.hasInterrupt(args || {})
: !!def.hasInterrupt : !!def.hasInterrupt
if (!hasInterrupt) { if (!hasInterrupt) {
// Auto-execute tools without interrupts - non-blocking // Auto-execute tools without interrupts
const ctx = createExecutionContext({ toolCallId: id, toolName: name }) const ctx = createExecutionContext({ toolCallId: id, toolName: name })
Promise.resolve() try {
.then(() => def.execute(ctx, args || {})) await def.execute(ctx, args || {})
.catch((execErr: any) => { } catch (execErr: any) {
logger.error('[SubAgent] Tool execution failed', { logger.error('[SubAgent] Tool execution failed', { id, name, error: execErr?.message })
id, }
name,
error: execErr?.message,
})
})
} }
} else { } else {
// Fallback to class-based tools - non-blocking // Fallback to class-based tools
const instance = getClientTool(id) const instance = getClientTool(id)
if (instance) { if (instance) {
const hasInterruptDisplays = !!instance.getInterruptDisplays?.() const hasInterruptDisplays = !!instance.getInterruptDisplays?.()
if (!hasInterruptDisplays) { if (!hasInterruptDisplays) {
Promise.resolve() try {
.then(() => instance.execute(args || {})) await instance.execute(args || {})
.catch((execErr: any) => { } catch (execErr: any) {
logger.error('[SubAgent] Class tool execution failed', { logger.error('[SubAgent] Class tool execution failed', {
id, id,
name, name,
error: execErr?.message, error: execErr?.message,
})
}) })
}
} }
} }
} }
@@ -2518,13 +2515,6 @@ export const useCopilotStore = create<CopilotStore>()(
// Call copilot API // Call copilot API
const apiMode: 'ask' | 'agent' | 'plan' = const apiMode: 'ask' | 'agent' | 'plan' =
mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent' mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent'
// Extract slash commands from contexts (lowercase) and filter them out from contexts
const commands = contexts
?.filter((c) => c.kind === 'slash_command' && 'command' in c)
.map((c) => (c as any).command.toLowerCase()) as string[] | undefined
const filteredContexts = contexts?.filter((c) => c.kind !== 'slash_command')
const result = await sendStreamingMessage({ const result = await sendStreamingMessage({
message: messageToSend, message: messageToSend,
userMessageId: userMessage.id, userMessageId: userMessage.id,
@@ -2536,8 +2526,7 @@ export const useCopilotStore = create<CopilotStore>()(
createNewChat: !currentChat, createNewChat: !currentChat,
stream, stream,
fileAttachments, fileAttachments,
contexts: filteredContexts, contexts,
commands: commands?.length ? commands : undefined,
abortSignal: abortController.signal, abortSignal: abortController.signal,
}) })
@@ -2629,14 +2618,13 @@ export const useCopilotStore = create<CopilotStore>()(
), ),
isSendingMessage: false, isSendingMessage: false,
isAborting: false, isAborting: false,
// Keep abortController so streaming loop can check signal.aborted abortController: null,
// It will be nulled when streaming completes or new message starts
})) }))
} else { } else {
set({ set({
isSendingMessage: false, isSendingMessage: false,
isAborting: false, isAborting: false,
// Keep abortController so streaming loop can check signal.aborted abortController: null,
}) })
} }
@@ -2665,7 +2653,7 @@ export const useCopilotStore = create<CopilotStore>()(
} catch {} } catch {}
} }
} catch { } catch {
set({ isSendingMessage: false, isAborting: false }) set({ isSendingMessage: false, isAborting: false, abortController: null })
} }
}, },
@@ -3166,7 +3154,6 @@ export const useCopilotStore = create<CopilotStore>()(
: msg : msg
), ),
isSendingMessage: false, isSendingMessage: false,
isAborting: false,
abortController: null, abortController: null,
currentUserMessageId: null, currentUserMessageId: null,
})) }))

View File

@@ -85,7 +85,6 @@ export type ChatContext =
| { kind: 'knowledge'; knowledgeId?: string; label: string } | { kind: 'knowledge'; knowledgeId?: string; label: string }
| { kind: 'templates'; templateId?: string; label: string } | { kind: 'templates'; templateId?: string; label: string }
| { kind: 'docs'; label: string } | { kind: 'docs'; label: string }
| { kind: 'slash_command'; command: string; label: string }
import type { CopilotChat as ApiCopilotChat } from '@/lib/copilot/api' import type { CopilotChat as ApiCopilotChat } from '@/lib/copilot/api'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -297,7 +297,7 @@ describe('workflow store', () => {
expectEdgeConnects(edges, 'block-1', 'block-2') expectEdgeConnects(edges, 'block-1', 'block-2')
}) })
it('should not add duplicate connections', () => { it('should not add duplicate edges', () => {
const { addBlock, batchAddEdges } = useWorkflowStore.getState() const { addBlock, batchAddEdges } = useWorkflowStore.getState()
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 }) addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
@@ -309,6 +309,17 @@ describe('workflow store', () => {
const state = useWorkflowStore.getState() const state = useWorkflowStore.getState()
expectEdgeCount(state, 1) 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', () => { describe('batchRemoveEdges', () => {

View File

@@ -9,12 +9,7 @@ import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { import { getUniqueBlockName, mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
filterNewEdges,
getUniqueBlockName,
mergeSubblockState,
normalizeName,
} from '@/stores/workflows/utils'
import type { import type {
Position, Position,
SubBlockState, SubBlockState,
@@ -501,11 +496,25 @@ export const useWorkflowStore = create<WorkflowStore>()(
batchAddEdges: (edges: Edge[]) => { batchAddEdges: (edges: Edge[]) => {
const currentEdges = get().edges const currentEdges = get().edges
const filtered = filterNewEdges(edges, currentEdges)
const newEdges = [...currentEdges] const newEdges = [...currentEdges]
const existingEdgeIds = new Set(currentEdges.map((e) => e.id))
// Track existing connections to prevent duplicates (same source->target)
const existingConnections = new Set(currentEdges.map((e) => `${e.source}->${e.target}`))
for (const edge of filtered) { for (const edge of edges) {
// Skip if edge ID already exists
if (existingEdgeIds.has(edge.id)) continue
// Skip self-referencing edges
if (edge.source === edge.target) continue
// Skip if connection already exists (same source and target)
const connectionKey = `${edge.source}->${edge.target}`
if (existingConnections.has(connectionKey)) continue
// Skip if would create a cycle
if (wouldCreateCycle([...newEdges], edge.source, edge.target)) continue if (wouldCreateCycle([...newEdges], edge.source, edge.target)) continue
newEdges.push({ newEdges.push({
id: edge.id || crypto.randomUUID(), id: edge.id || crypto.randomUUID(),
source: edge.source, source: edge.source,
@@ -515,6 +524,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
type: edge.type || 'default', type: edge.type || 'default',
data: edge.data || {}, data: edge.data || {},
}) })
existingEdgeIds.add(edge.id)
existingConnections.add(connectionKey)
} }
const blocks = get().blocks const blocks = get().blocks
@@ -639,8 +650,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
const newName = getUniqueBlockName(block.name, get().blocks) const newName = getUniqueBlockName(block.name, get().blocks)
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const mergedBlock = mergeSubblockState(get().blocks, id)[id]
const mergedBlock = mergeSubblockState(get().blocks, activeWorkflowId || undefined, id)[id]
const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce( const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce(
(acc, [subId, subBlock]) => ({ (acc, [subId, subBlock]) => ({
@@ -669,6 +679,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
parallels: get().generateParallelBlocks(), parallels: get().generateParallelBlocks(),
} }
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) { if (activeWorkflowId) {
const subBlockValues = const subBlockValues =
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {} useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,14 +26,6 @@ export const a2aSendMessageTool: ToolConfig<A2ASendMessageParams, A2ASendMessage
type: 'string', type: 'string',
description: 'Context ID for conversation continuity', 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: { apiKey: {
type: 'string', type: 'string',
description: 'API key for authentication', description: 'API key for authentication',
@@ -43,21 +35,7 @@ export const a2aSendMessageTool: ToolConfig<A2ASendMessageParams, A2ASendMessage
request: { request: {
url: '/api/tools/a2a/send-message', url: '/api/tools/a2a/send-message',
method: 'POST', 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) => { transformResponse: async (response: Response) => {

View File

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

View File

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

View File

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

View File

@@ -1,188 +0,0 @@
import type { LangsmithCreateRunParams, LangsmithCreateRunResponse } from '@/tools/langsmith/types'
import { normalizeLangsmithRunPayload } from '@/tools/langsmith/utils'
import type { ToolConfig } from '@/tools/types'
export const langsmithCreateRunTool: ToolConfig<
LangsmithCreateRunParams,
LangsmithCreateRunResponse
> = {
id: 'langsmith_create_run',
name: 'LangSmith Create Run',
description: 'Forward a single run to LangSmith for ingestion.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'LangSmith API key',
},
id: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Unique run identifier',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Run name',
},
run_type: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Run type (tool, chain, llm, retriever, embedding, prompt, parser)',
},
start_time: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Run start time in ISO-8601 format',
},
end_time: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Run end time in ISO-8601 format',
},
inputs: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Inputs payload',
},
outputs: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Outputs payload',
},
extra: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Additional metadata (extra)',
},
tags: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Array of tag strings',
},
parent_run_id: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Parent run ID',
},
trace_id: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Trace ID',
},
session_id: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Session ID',
},
session_name: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Session name',
},
status: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Run status',
},
error: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Error details',
},
dotted_order: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Dotted order string',
},
events: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Structured events array',
},
},
request: {
url: () => 'https://api.smith.langchain.com/runs',
method: 'POST',
headers: (params) => ({
'X-Api-Key': params.apiKey,
'Content-Type': 'application/json',
}),
body: (params) => {
const { payload } = normalizeLangsmithRunPayload(params)
const normalizedPayload: Record<string, unknown> = {
...payload,
name: payload.name?.trim(),
inputs: params.inputs,
outputs: params.outputs,
extra: params.extra,
tags: params.tags,
status: params.status,
error: params.error,
events: params.events,
}
return Object.fromEntries(
Object.entries(normalizedPayload).filter(([, value]) => value !== undefined)
)
},
},
transformResponse: async (response, params) => {
const runId = params ? normalizeLangsmithRunPayload(params).runId : null
const data = (await response.json()) as Record<string, unknown>
const directMessage =
typeof (data as { message?: unknown }).message === 'string'
? (data as { message: string }).message
: null
const nestedPayload =
runId && typeof data[runId] === 'object' && data[runId] !== null
? (data[runId] as Record<string, unknown>)
: null
const nestedMessage =
nestedPayload && typeof nestedPayload.message === 'string' ? nestedPayload.message : null
return {
success: true,
output: {
accepted: true,
runId: runId ?? null,
message: directMessage ?? nestedMessage ?? null,
},
}
},
outputs: {
accepted: {
type: 'boolean',
description: 'Whether the run was accepted for ingestion',
},
runId: {
type: 'string',
description: 'Run identifier provided in the request',
optional: true,
},
message: {
type: 'string',
description: 'Response message from LangSmith',
optional: true,
},
},
}

View File

@@ -1,112 +0,0 @@
import type {
LangsmithCreateRunsBatchParams,
LangsmithCreateRunsBatchResponse,
LangsmithRunPayload,
} from '@/tools/langsmith/types'
import { normalizeLangsmithRunPayload } from '@/tools/langsmith/utils'
import type { ToolConfig } from '@/tools/types'
export const langsmithCreateRunsBatchTool: ToolConfig<
LangsmithCreateRunsBatchParams,
LangsmithCreateRunsBatchResponse
> = {
id: 'langsmith_create_runs_batch',
name: 'LangSmith Create Runs Batch',
description: 'Forward multiple runs to LangSmith in a single batch.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'LangSmith API key',
},
post: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Array of new runs to ingest',
},
patch: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Array of runs to update/patch',
},
},
request: {
url: () => 'https://api.smith.langchain.com/runs/batch',
method: 'POST',
headers: (params) => ({
'X-Api-Key': params.apiKey,
'Content-Type': 'application/json',
}),
body: (params) => {
const payload: Record<string, unknown> = {
post: params.post
? params.post.map((run) => normalizeLangsmithRunPayload(run).payload)
: undefined,
patch: params.patch
? params.patch.map((run) => normalizeLangsmithRunPayload(run).payload)
: undefined,
}
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== undefined))
},
},
transformResponse: async (response, params) => {
const data = (await response.json()) as Record<string, unknown>
const directMessage =
typeof (data as { message?: unknown }).message === 'string'
? (data as { message: string }).message
: null
const messages = Object.values(data)
.map((value) => {
if (typeof value !== 'object' || value === null) {
return null
}
const messageValue = (value as Record<string, unknown>).message
return typeof messageValue === 'string' ? messageValue : null
})
.filter((value): value is string => Boolean(value))
const collectRunIds = (runs?: LangsmithRunPayload[]) =>
runs?.map((run) => normalizeLangsmithRunPayload(run).runId) ?? []
return {
success: true,
output: {
accepted: true,
runIds: [...collectRunIds(params?.post), ...collectRunIds(params?.patch)],
message: directMessage ?? null,
messages: messages.length ? messages : undefined,
},
}
},
outputs: {
accepted: {
type: 'boolean',
description: 'Whether the batch was accepted for ingestion',
},
runIds: {
type: 'array',
description: 'Run identifiers provided in the request',
items: {
type: 'string',
},
},
message: {
type: 'string',
description: 'Response message from LangSmith',
optional: true,
},
messages: {
type: 'array',
description: 'Per-run response messages, when provided',
optional: true,
items: {
type: 'string',
},
},
},
}

View File

@@ -1,2 +0,0 @@
export { langsmithCreateRunTool } from '@/tools/langsmith/create_run'
export { langsmithCreateRunsBatchTool } from '@/tools/langsmith/create_runs_batch'

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