Compare commits

..

8 Commits

Author SHA1 Message Date
Emir Karabeg
d9d5e6000b improvement: features, footer, tab modals 2026-03-14 22:31:09 -07:00
Emir Karabeg
d557bf527c improvement(landing): features 2026-03-14 16:15:21 -07:00
Emir Karabeg
dc98812bd7 improvement: landing, sidebar, globals, buttons 2026-03-14 15:03:53 -07:00
Vikhyath Mondreti
c738226c06 fix(file): bun issues with new file creation (#3582)
* fix(files): new file bun error

* update constant

* fix types
2026-03-14 10:56:13 -07:00
Waleed
8f15be23a0 fix(ashby): add secretToken to webhook creation and fix trigger UX (#3580)
* fix(ashby): add secretToken to webhook creation and fix trigger UX

* fix(toast): restore useMemo for context value to prevent unnecessary re-renders

* fix(notifications): track exit animation timeout so pauseAll can cancel it

* fix(notifications): use isPausedRef to guard exit timeout instead of synthetic timer keys

* fix(notifications): clear pending timers when notification stack empties
2026-03-14 06:26:06 -07:00
Waleed
b2d146ca0a improvement(mothership): message queueing for home chat (#3576)
* improvement(mothership): message queueing for home chat

* fix(mothership): address PR review — move FileAttachmentForApi to types, defer onEditValueConsumed to effect, await sendMessage in sendNow

* fix(mothership): replace updater side-effect with useEffect ref sync, move sendMessageRef to useLayoutEffect

* fix(mothership): clear message queue on chat switch while sending

* fix(mothership): remove stale isSending from handleKeyDown deps

* fix(mothership): guard sendNow against double-click duplicate sends

* fix(mothership): simplify queue callbacks — drop redundant deps and guard ref

- Remove `setMessageQueue` from useCallback deps (stable setter, never changes)
- Replace `sendNowProcessingRef` double-click guard with eager `messageQueueRef` update
- Simplify `editQueuedMessage` with same eager-ref pattern for consistency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(mothership): clear edit value on nav, stop queue drain on send failure

- Reset editingInputValue when chatId changes so stale edit text
  doesn't leak into the next chat
- Pass error flag to finalize so queue is cleared (not drained) when
  sendMessage fails — prevents cascading failures on auth expiry or
  rate limiting from silently consuming every queued message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(mothership): eagerly update messageQueueRef in removeFromQueue

Match the pattern used by sendNow and editQueuedMessage — update the
ref synchronously so finalize's microtask cannot read a stale queue
and drain a message the user just removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(mothership): mark onSendNow as explicit fire-and-forget

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 06:24:00 -07:00
Waleed
d06aa1de7e fix(connectors): align connector scopes with oauth config and fix kb modal UX (#3573)
* fix(connectors): align connector scopes with oauth config and fix kb modal UX

* fix(connectors): restore onCheckedChange for keyboard accessibility

* feat(connectors): add dynamic selectors to knowledge base connector config

Replace manual ID text inputs with dynamic selector dropdowns that fetch
options from the existing selector registry. Users can toggle between
selector and manual input via canonical pairs (basic/advanced mode).

Adds selector support to 12 connectors: Airtable (cascading base→table),
Slack, Gmail, Google Calendar, Linear (cascading team→project), Jira,
Confluence, MS Teams (cascading team→channel), Notion, Asana, Webflow,
and Outlook. Dependency clearing propagates across canonical siblings to
prevent stale cross-mode data on submit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* updated animated blocks UI

* fix(connectors): clear canonical siblings of dependents and resolve active mode values

Fixes three issues from PR review:
- Dependency clearing now includes canonical siblings of dependent fields
  (e.g., changing base clears both tableSelector AND tableIdOrName)
- Selector context and depsResolved now resolve dependency values through
  the active canonical mode, not just the raw depFieldId
- Tooltip text changed from "Switch to manual ID" to "Switch to manual input"
  to correctly describe dropdown fallbacks (e.g., Outlook folder)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: linter class ordering fixes and docs link update

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(connectors): reset apiKeyFocused on connector re-selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 03:31:07 -07:00
Siddharth Ganesan
5b9f0d73c2 feat(mothership): mothership (#3411)
* Fix lint

* improvement(sidebar): loading

* fix(sidebar): use client-generated UUIDs for stable optimistic updates (#3439)

* fix(sidebar): use client-generated UUIDs for stable optimistic updates

* fix(folders): use zod schema validation for folder create API

Replace inline UUID regex with zod schema validation for consistency
with other API routes. Update test expectations accordingly.

* fix(sidebar): add client UUID to single workflow duplicate hook

The useDuplicateWorkflow hook was missing newId: crypto.randomUUID(),
causing the same temp-ID-swap issue for single workflow duplication
from the context menu.

* fix(folders): avoid unnecessary Set re-creation in replaceOptimisticEntry

Only create new expandedFolders/selectedFolders Sets when tempId
differs from data.id. In the common happy path (client-generated UUIDs),
this avoids unnecessary Zustand state reference changes and re-renders.

* Mothership block logs

* Fix mothership block logs

* improvement(knowledge): make connector-synced document chunks readonly (#3440)

* improvement(knowledge): make connector-synced document chunks readonly

* fix(knowledge): enforce connector chunk readonly on server side

* fix(knowledge): disable toggle and delete actions for connector-synced chunks

* Job exeuction logs

* Job logs

* fix(connectors): remove unverifiable requiredScopes for Linear connector

* fix(connectors): remove legacy requiredScopes from Jira and Confluence connectors

Jira and Confluence OAuth tokens don't return legacy scope names like
read:jira-work or read:confluence-content.all, causing the 'Update access'
banner to always appear. Set requiredScopes to empty array like Linear.

* feat(tasks): add rename to task context menu (#3442)

* Revert "fix(connectors): remove legacy requiredScopes from Jira and Confluence connectors"

This reverts commit a0be3ff414.

* fix(connectors): restore Linear connector requiredScopes

Linear OAuth does return scopes in the token response. The previous
fix of emptying requiredScopes was based on an incorrect assumption.
Restoring requiredScopes: ['read'] as it should work correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(knowledge): pass workspaceId to useOAuthCredentials in connector card

The ConnectorCard was calling useOAuthCredentials(providerId) without
a workspaceId, causing the credentials API to return an empty array.
This meant the credential lookup always failed, getMissingRequiredScopes
received undefined, and the "Update access" banner always appeared.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix oauth link callback from mothership task

* feat(connectors): add Fireflies connector and API key auth support (#3448)

* feat(connectors): add Fireflies connector and API key auth support

Extend the connector system to support both OAuth and API key authentication
via a discriminated union (`ConnectorAuthConfig`). Add Fireflies as the first
API key connector, syncing meeting transcripts via the Fireflies GraphQL API.

Schema changes:
- Make `credentialId` nullable (null for API key connectors)
- Add `encryptedApiKey` column (AES-256-GCM encrypted, null for OAuth)

This eliminates the `'_apikey_'` sentinel and inline `sourceConfig._encryptedApiKey`
patterns, giving each auth mode its own clean column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(fireflies): allow 0 for maxTranscripts (means unlimited)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Add context

* fix(fireflies): correct types from live API validation (#3450)

* fix(fireflies): correct types from live API validation

- speakers.id is number, not string (API returns 0, 1, 2...)
- summary.action_items is a single string, not string[]
- Update formatTranscriptContent to handle action_items as string

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(fireflies): correct tool types from live API validation

- FirefliesSpeaker.id: string -> number
- FirefliesSentence.speaker_id: string -> number
- FirefliesSpeakerAnalytics.speaker_id: string -> number
- FirefliesSummary.action_items: string[] -> string
- FirefliesSummary.outline: string[] -> string
- FirefliesSummary.shorthand_bullet: string[] -> string
- FirefliesSummary.bullet_gist: string[] -> string
- FirefliesSummary.topics_discussed: string[] -> string

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(knowledge): add connector tools and expand document metadata (#3452)

* feat(knowledge): add connector tools and expand document metadata

* fix(knowledge): address PR review feedback on new tools

* fix(knowledge): remove unused params from get_document transform

* refactor, improvement

* fix: correct knowledge block canonical pair pattern and subblock migration

- Rename manualDocumentId to documentId (advanced subblock ID should match
  canonicalParamId, consistent with airtable/gmail patterns)
- Fix documentSelector.dependsOn to reference knowledgeBaseSelector (basic
  depends on basic, not advanced)
- Remove unnecessary documentId migration (ID unchanged from main)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

* fix: resolve post-merge test and lint failures

- airtable: sync tableSelector condition with tableId (add getSchema)
- backfillCanonicalModes test: add documentId mode to prevent false backfill
- schedule PUT test: use invalid action string now that disable is valid
- schedule execute tests: add ne mock, sourceType field, use
  mockReturnValueOnce for two db.update calls
- knowledge tools: fix biome formatting (single-line arrow functions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fixes

* Fixes

* Clean vfs

* Fix

* Fix lint

* fix(connectors): add rate limiting, concurrency controls, and bug fixes (#3457)

* fix(connectors): add rate limiting, concurrency controls, and bug fixes across knowledge connectors

- Add Retry-After header support to fetchWithRetry for all 18 connectors
- Batch concurrent API calls (concurrency 5) in Dropbox, Google Docs, Google Drive, OneDrive, SharePoint
- Batch concurrent API calls (concurrency 3) in Notion to match 3 req/s limit
- Cache GitHub tree in syncContext to avoid re-fetching on every pagination page
- Batch GitHub blob fetches with concurrency 5
- Fix GitHub base64 decoding: atob() → Buffer.from() for UTF-8 safety
- Fix HubSpot OAuth scope: 'tickets' → 'crm.objects.tickets.read' (v3 API)
- Fix HubSpot syncContext key: totalFetched → totalDocsFetched for consistency
- Add jitter to nextSyncAt (10% of interval, capped at 5min) to prevent thundering herd
- Fix Date consistency in connector DELETE route

* fix(connectors): address PR review feedback on retry and SharePoint batching

- Remove 120s cap on Retry-After — pass all values through to retry loop
- Add maxDelayMs guard: if Retry-After exceeds maxDelayMs, throw immediately
  instead of hammering with shorter intervals (addresses validate timeout concern)
- Add early exit in SharePoint batch loop when maxFiles limit is reached
  to avoid unnecessary API calls

* fix(connectors): cap Retry-After at maxDelayMs instead of aborting

Match Google Cloud SDK behavior: when Retry-After exceeds maxDelayMs,
cap the wait to maxDelayMs and log a warning, rather than throwing
immediately. This ensures retries are bounded in duration while still
respecting server guidance within the configured limit.

* fix(connectors): add early-exit guard to Dropbox, Google Docs, OneDrive batch loops

Match the SharePoint fix — skip remaining batches once maxFiles limit
is reached to avoid unnecessary API calls.

* improvement(turbo): align turborepo config with best practices (#3458)

* improvement(turbo): align turborepo config with best practices

* fix(turbo): address PR review feedback

* fix(turbo): add lint:check task for read-only lint+format CI checks

lint:check previously delegated to format:check which only checked
formatting. Now it runs biome check (no --write) which enforces both
lint rules and formatting without mutating files.

* upgrade turbo

* improvement(perf): apply react and js performance optimizations across codebase (#3459)

* improvement(perf): apply react and js performance optimizations across codebase

- Parallelize independent DB queries with Promise.all in API routes
- Defer PostHog and OneDollarStats via dynamic import() to reduce bundle size
- Use functional setState in countdown timers to prevent stale closures
- Replace O(n*m) .filter().find() with Set-based O(n) lookups in undo-redo
- Use .toSorted() instead of .sort() for immutable state operations
- Use lazy initializers for useState(new Set()) across 20 components
- Remove useMemo wrapping trivially cheap expressions (typeof, ternary, template strings)
- Add passive: true to scroll event listener

* fix(perf): address PR review feedback

- Extract IIFE Set patterns to named consts for readability in use-undo-redo
- Hoist Set construction above loops in BATCH_UPDATE_PARENT cases
- Add .catch() error handler to PostHog dynamic import
- Convert session-provider posthog import to dynamic import() to complete bundle split

* fix(analytics): add .catch() to onedollarstats dynamic import

* improvement(resource): tables, files

* improvement(resources): all outer page structure complete

* refactor(queries): comprehensive TanStack Query best practices audit (#3460)

* refactor: comprehensive TanStack Query best practices audit and migration

- Add AbortSignal forwarding to all 41 queryFn implementations for proper request cancellation
- Migrate manual fetch patterns to useMutation hooks (useResetPassword, useRedeemReferralCode, usePurchaseCredits, useImportWorkflow, useOpenBillingPortal, useAllowedMcpDomains)
- Migrate standalone hooks to TanStack Query (use-next-available-slot, use-mcp-server-test, use-webhook-management, use-referral-attribution)
- Fix query key factories: add missing `all` keys, replace inline keys with factory methods
- Fix optimistic mutations: use onSettled instead of onSuccess for cache reconciliation
- Replace overly broad cache invalidations with targeted key invalidation
- Remove keepPreviousData from static-key queries where it provides no benefit
- Add staleTime to queries missing explicit cache duration
- Fix `any` type in UpdateSettingParams with proper GeneralSettings typing
- Remove dead code: loadingWebhooks/checkedWebhooks from subblock store, unused helper functions
- Update settings components (general, debug, referral-code, credit-balance, subscription, mcp) to use mutation state instead of manual useState for loading/error/success

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove unstable mutation object from useCallback deps

openBillingPortal mutation object is not referentially stable,
but .mutate() is stable in TanStack Query v5. Remove from deps
to prevent unnecessary handleBadgeClick recreations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add missing byWorkflows invalidation to useUpdateTemplate

The onSettled handler was missing the byWorkflows() invalidation
that was dropped during the onSuccess→onSettled migration. Without
this, the deploy modal (useTemplateByWorkflow) would show stale data
after a template update.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add TanStack Query best practices to CLAUDE.md and cursor rules

Add comprehensive React Query best practices covering:
- Hierarchical query key factories with intermediate plural keys
- AbortSignal forwarding in all queryFn implementations
- Targeted cache invalidation over broad .all invalidation
- onSettled for optimistic mutation cache reconciliation
- keepPreviousData only on variable-key queries
- No manual fetch in components rule
- Stable mutation references in useCallback deps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review feedback

- Fix syncedRef regression in use-webhook-management: only set
  syncedRef.current=true when webhook is found, so re-sync works
  after webhook creation (e.g., post-deploy)
- Remove redundant detail(id) invalidation from useUpdateTemplate
  onSettled since onSuccess already populates cache via setQueryData

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address second round of PR review feedback

- Reset syncedRef when blockId changes in use-webhook-management so
  component reuse with a different block syncs the new webhook
- Add response.ok check in postAttribution so non-2xx responses
  throw and trigger TanStack Query retry logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use lists() prefix invalidation in useCreateWorkspaceCredential

Use workspaceCredentialKeys.lists() instead of .list(workspaceId) so
filtered list queries are also invalidated on credential creation,
matching the pattern used by update and delete mutations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address third round of PR review feedback

- Add nullish coalescing fallback for bonusAmount in referral-code
  to prevent rendering "undefined" when server omits the field
- Reset syncedRef when queryEnabled becomes false so webhook data
  re-syncs when the query is re-enabled without component remount

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address fourth round of PR review feedback

- Add AbortSignal to testMcpServerConnection for consistency
- Wrap handleTestConnection in try/catch for mutateAsync error handling
- Replace broad subscriptionKeys.all with targeted users()/usage() invalidation
- Add intermediate users() key to subscription key factory for prefix matching
- Add comment documenting syncedRef null-webhook behavior
- Fix api-keys.ts silent error swallowing on non-ok responses
- Move deployments.ts cache invalidation from onSuccess to onSettled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: achieve full TanStack Query best practices compliance

- Add intermediate plural keys to api-keys, deployments, and schedules
  key factories for prefix-based invalidation support
- Change copilot-keys from refetchQueries to invalidateQueries
- Add signal parameter to organization.ts fetch functions (better-auth
  client does not support AbortSignal, documented accordingly)
- Move useCreateMcpServer invalidation from onSuccess to onSettled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* ran lint

* Fix tables row count

* Update mothership to match copilot in logs

* improvement(resource): layout

* fix(knowledge): compute KB tokenCount from documents instead of stale column (#3463)

The knowledge_base.token_count column was initialized to 0 and never
updated. Replace with COALESCE(SUM(document.token_count), 0) in all
read queries, which already JOIN on documents with GROUP BY.

* improvement(resources): layout and items

* feat(knowledge): add v1 knowledge base API, Obsidian/Evernote connectors, and docs (#3465)

* feat(knowledge): add v1 knowledge base API, Obsidian/Evernote connectors, and docs

- Add v1 REST API for knowledge bases (CRUD, document management, vector search)
- Add Obsidian and Evernote knowledge base connectors
- Add file type validation to v1 file and document upload endpoints
- Update OpenAPI spec with knowledge base endpoints and schemas
- Add connectors documentation page
- Apply query hook formatting improvements

* fix(knowledge): address PR review feedback

- Remove validateFileType from v1/files route (general file upload, not document-only)
- Reject tag filters when searching multiple KBs (tag defs are KB-specific)
- Cache tag definitions to avoid duplicate getDocumentTagDefinitions call
- Fix Obsidian connector silent empty results when syncContext is undefined

* improvement(connectors): add syncContext to getDocument, clean up caching

- Update docs to say 20+ connectors
- Add syncContext param to ConnectorConfig.getDocument interface
- Use syncContext in Evernote getDocument to cache tag/notebook maps
- Replace index-based cache check with Map keyed by KB ID in search route

* fix(knowledge): address second round of PR review feedback

- Fix Zod .default('text') overriding tag definition's actual fieldType
- Fix encodeURIComponent breaking multi-level folder paths in Obsidian
- Use 413 instead of 400 for file-too-large in document upload
- Add knowledge-bases to API reference docs navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(knowledge): prevent cross-workspace KB access in search

Filter accessible KBs by matching workspaceId from the request,
preventing users from querying KBs in other workspaces they have
access to but didn't specify.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(knowledge): audit resourceId, SSRF protection, recursion depth limit

- Fix recordAudit using knowledgeBaseId instead of newDocument.id
- Add SSRF validation to Obsidian connector (reject private/loopback URLs)
- Add max recursion depth (20) to listVaultFiles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(obsidian): remove SSRF check that blocks localhost usage

The Obsidian connector is designed to connect to the Local REST API
plugin running on localhost (127.0.0.1:27124). The SSRF check was
incorrectly blocking this primary use case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* improvement(resources): segmented API

* fix(execution): ensure background tasks await post-execution DB status updates (#3466)

The fire-and-forget IIFE in execution-core.ts for post-execution logging could be abandoned when trigger.dev tasks exit, leaving executions permanently stuck in "running" status. Store the promise on LoggingSession so background tasks can optionally await it before returning.

* improvement(resource): sorting and icons

* fix(resource): sorting

* improvement(settings): fix mcp modal, add option to edit JSON and add Sim as an MCP client (#3467)

* improvement(settings): fix mcp modal, add option to edit JSON and add Sim as an MCP client

* added docs link in sidebar

* ack comments

* ack comments

* fixed error msg

* feat(mothership): billing (#3464)

* Billing update

* more billing improvements

* credits UI

* credit purchase safety

* progress

* ui improvements

* fix cancel sub

* fix types

* fix daily refresh for teams

* make max features differentiated

* address bugbot comments

* address greptile comments

* revert isHosted

* address more comments

* fix org refresh bar

* fix ui rounding

* fix minor rounding

* fix upgrade issue for legacy plans

* fix formatPlanName

* fix email dispay names

* fix legacy team reference bugs

* referral bonus in credits

* fix org upgrade bug

* improve logs

* respect toggle for paid users

* fix landing page pro features and usage limit checks

* fixed query and usage

* add unit test

* address more comments

* enterprise guard

* fix limits bug

* pass period start/end for overage

* fix(sidebar): restore drag-and-drop for workflows and folders (#3470)

* fix(sidebar): restore drag-and-drop for workflows and folders

Made-with: Cursor

* update docs, unrelated

* improvement(tables): consolidation

* feat(schedules): add schedule creator modal for standalone jobs

Add modal to create standalone scheduled jobs from the Schedules page.
Includes POST API endpoint, useCreateSchedule mutation hook, and full
modal with schedule type selection, timezone, lifecycle, and live preview.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(schedules): add edit support with context menu for standalone jobs

* style(schedules): apply linter formatting

* improvement: tables, favicon

* feat(files): inline file viewer with text editing (#3475)

* feat(files): add inline file viewer with text editing and create file modal

Add file preview/edit functionality to the workspace files page. Text files
(md, json, txt, yaml, etc.) open in an editable textarea with Cmd/Ctrl+S save.
PDFs render in an iframe. New file button creates empty .md files via a modal.
Uses ResourceHeader breadcrumbs and ResourceOptionsBar for save/download/delete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* improvement(files): add UX polish, PR review fixes, and context menu

- Add unsaved changes guard modal (matching credentials manager pattern)
- Add delete confirmation modal for both viewer and context menu
- Add save status feedback (Save → Saving... → Saved)
- Add right-click context menu with Open, Download, Delete actions
- Add 50MB file size limit on content update API
- Add storage quota check before content updates
- Add response.ok guard on download to prevent corrupt files
- Add skeleton loading for pending file selection (prevents flicker)
- Fix updateContent in handleSave dependency array

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(files): propagate save errors and remove redundant sizeDiff

- Remove try/catch in TextEditor.handleSave so errors propagate to
  parent, which correctly shows save failure status
- Remove redundant inner sizeDiff declaration that shadowed outer scope

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(files): remove unused textareaRef

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(files): move Cmd+S to parent, add save error feedback, hide save for non-text files

- Move Cmd+S keyboard handler from TextEditor to Files so it goes
  through the parent handleSave with proper status management
- Add 'error' save status with red "Save failed" label that auto-resets
- Only show Save button for text-editable file types (md, txt, json, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* improvement(files): add save tooltip, deduplicate text-editable extensions

- Add Tooltip on Save button showing Cmd+S / Ctrl+S shortcut
- Export TEXT_EDITABLE_EXTENSIONS from file-viewer and reuse in files.tsx
  instead of duplicating the list inline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract isMacPlatform to shared utility

Move isMacPlatform() from global-commands-provider.tsx to
lib/core/utils/platform.ts so it can be reused by files.tsx tooltip
without duplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(files): deduplicate delete modal, use shared formatFileSize

- Extract DeleteConfirmModal component to eliminate duplicate modal
  markup between viewer and list modes
- Replace local formatFileSize with shared utility from file-utils.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(files): fix a11y label lint error and remove mutation object from useCallback deps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(files): add isDirty guard on handleSave, return proper HTTP status codes

Prevents "Saving → Saved" flash when pressing Cmd+S with no changes.
Returns 404 for file-not-found and 402 for quota-exceeded instead of 500.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(files): reset isDirty/saveStatus on delete and discard, remove deprecated navigator.platform

- Clear isDirty and saveStatus when deleting the currently-viewed file to
  prevent spurious beforeunload prompts
- Reset saveStatus on discard to prevent stale "Save failed" when opening
  another file
- Remove deprecated navigator.platform, userAgent fallback covers all cases

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(files): prevent concurrent saves on rapid Cmd+S, add YAML MIME types

- Add saveStatus === 'saving' guard to handleSave to prevent duplicate
  concurrent PUT requests from rapid keyboard shortcuts
- Add yaml/yml MIME type mappings to getMimeTypeFromExtension

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(files): reuse shared extension constants, parallelize cancelQueries

- Replace hand-rolled SUPPORTED_EXTENSIONS with composition from existing
  SUPPORTED_DOCUMENT/AUDIO/VIDEO_EXTENSIONS in validation.ts
- Parallelize sequential cancelQueries calls in delete mutation onMutate

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(files): guard handleCreate against duplicate calls while pending

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(files): show upload progress on the Upload button, not New file

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(files): use ref-based guard for create pending state to avoid stale closure

The uploadFile.isPending check was stale because the mutation object
is excluded from useCallback deps (per codebase convention). Using a
ref ensures the guard works correctly across rapid Enter key presses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* cleanup(files): use shared icon import, remove no-op props, wrap handler in useCallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* improvement: tables, dropdown

* improvement(docs): align sidebar method badges and polish API reference styling (#3484)

* improvement(docs): align sidebar method badges and polish API reference styling

* fix(docs): revert className prop on DocsPage for CI compatibility

* fix(docs): restore oneOf schema for delete rows and use rem units in CSS

* fix(docs): replace :has() selectors with direct className for reliable prod layout

The API docs layout was intermittently narrow in production because CSS
:has(.api-page-header) selectors are unreliable in Tailwind v4 production
builds. Apply className="openapi-page" directly to DocsPage and replace
all 64 :has() selectors with .openapi-page class targeting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(docs): bypass TypeScript check for className prop on DocsPage

Use spread with type assertion to pass className to DocsPage, working
around a CI type resolution issue where the prop exists at runtime but
is not recognized by TypeScript in the Vercel build environment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(docs): use inline style tag for grid layout, revert CSS to :has() selectors

The className prop on DocsPage doesn't exist in the fumadocs-ui version
resolved on Vercel, so .openapi-page was never applied and all 64 CSS
rules broke. Revert to :has(.api-page-header) selectors for styling and
use an inline <style> tag for the critical grid-column layout override,
which is SSR'd and doesn't depend on any CSS selector matching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(docs): add pill styling to footer navigation method badges

The footer nav badges (POST, GET, etc.) had color from data-method rules
but lacked the structural pill styling (padding, border-radius, font-size).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(docs): use named grid lines instead of numeric column indices (#3487)

Root cause: the fumadocs grid template has 3 columns in production but
5 columns in local dev. Our CSS used `grid-column: 3 / span 2` which
targeted the wrong column in the 3-column grid, placing content in
the near-zero-width TOC column instead of the main content column.

Fix: use `grid-column: main-start / toc-end` which uses CSS named grid
lines from grid-template-areas, working regardless of column count.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* improvement(resource): layout

* improvement: icon, resource header options

* improvement: icons

* fix(files): icon

* feat(tables): column operations, row ordering, V1 API (#3488)

* feat(tables): add column operations, row ordering, V1 columns API, and OpenAPI spec

Adds column rename/delete/type change/constraint updates to the tables module,
row ordering via position column, UI metadata schema, V1 public API for column
operations with rate limiting and audit logging, and OpenAPI documentation.

Key changes:
- Service-layer column operations with validation (name pattern, type compatibility, unique/required constraints)
- Position column on user_table_rows with composite index for efficient ordering
- V1 /api/v1/tables/{tableId}/columns endpoint (POST/PATCH/DELETE) with rate limiting and audit
- Shared Zod schemas extracted to table/utils.ts using COLUMN_TYPES constant
- Targeted React Query invalidation (row vs schema mutations) with consistent onSettled usage
- OpenAPI 3.1.0 spec for columns endpoint with code samples
- Position field added to all row response mappings for consistency
- Sort fallback to position ordering when buildSortClause returns null

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tables): use specific error prefixes instead of broad "Cannot" match

Prevents internal TypeErrors (e.g. "Cannot read properties of undefined")
from leaking as 400 responses. Now matches only domain-specific errors:
"Cannot delete the last column" and "Cannot set column".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tables): reject Infinity and NaN in number type compatibility check

Number.isFinite rejects Infinity, -Infinity, and NaN, preventing
non-finite values from passing column type validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tables): invalidate table list on row create/delete for stale rowCount

Row create and delete mutations now invalidate the table list cache since
it includes a computed rowCount. Row updates (which don't change count)
continue to only invalidate row queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tables): add column name length check, deduplicate name gen, reset pagination on clear

- Add MAX_COLUMN_NAME_LENGTH validation to addTableColumn (was missing,
  renameColumn already had it)
- Extract generateColumnName helper to eliminate triplicated logic across
  handleAddColumn, handleInsertColumnLeft, handleInsertColumnRight
- Reset pagination to page 0 when clearing sort/filter to prevent showing
  empty pages after narrowing filters are removed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: hoist tableId above try block in V1 columns route, add detail invalidation to invalidateRowCount

- V1 columns route: `tableId` was declared inside `try` but referenced in
  `catch` logger.error, causing undefined in error logs. Hoisted `await params`
  above try in all three handlers (POST, PATCH, DELETE).
- invalidateRowCount: added `tableKeys.detail(tableId)` invalidation since the
  single-table GET response includes `rowCount`, which becomes stale after
  row create/delete without this.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add position to all row mutation responses, remove dead filter code

- Add `position` field to POST (single + batch) and PATCH row responses
  across both internal and V1 routes, matching GET responses and OpenAPI spec.
- Remove unused `filterConfig`, `handleFilterToggle`, `handleFilterClear`,
  and `activeFilters` — dead code left over from merge conflict resolution.
  `handleFilterApply` (the one actually wired to JSX) is preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: invalidateTableSchema now also invalidates table list cache

Column add/rename/delete/update mutations now invalidate tableKeys.list()
since the list endpoint returns schema.columns for each table. Without this,
the sidebar table list would show stale column schemas until staleTime expires.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: replace window.prompt/confirm with emcn Modal dialogs

Replace non-standard browser dialogs with proper emcn Modal components
to match the existing codebase pattern (e.g. delete table confirmation).

- Column rename: Modal with Input field + Enter key support
- Column delete: Modal with destructive confirmation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* update schedule creation ui and run lint

* improvement: logs

* improvement(tables): multi-select and efficiencies

* Table tools

* improvement(folder-selection): folder deselection + selection order should match visual

* fix(selections): more nested folder inaccuracies

* Tool updates

* Store tool call results

* fix(landing): wire agent input to mothership

* feat(mothership): resource viewer

* fix tests

* fix(streaming): smoother streaming with throttled rendering, ResizeObserver scroll, and batched updates (#3471)

* fix(streaming): smoother streaming with throttled rendering, ResizeObserver scroll, and batched updates

- Add useThrottledValue hook (100ms trailing-edge throttle) to gate DOM re-renders during streaming across all chat surfaces
- Replace 100ms setInterval scroll polling with ResizeObserver-based auto-scroll, programmatic scroll timestamp tracking, and nested [data-scrollable] region handling
- Extract processContentBuffer from inline content handler for cleaner code organization in copilot SSE handlers
- Add RAF-based update batching (50ms max interval) to floating chat and home chat streaming paths
- Add useProgressiveList hook for progressive rendering of long conversation histories via requestAnimationFrame

Made-with: Cursor

* ack PR comments

* fix search modal

* more comments

* ack comments

* count

* ack comments

* ack comment

* improvement(mothership): worklfow resource

* Fix tool call persistence in chat

* Tool results

* Fix error status

* File uploads to mothership

* feat(templates): landing page templates workflow states

* improvement(mothership): chat stability

* improvement(mothership): chat history and stability

* improvement(tables): click-to-select navigation, inline rename, column resize (#3496)

* improvement(tables): click-to-select navigation, inline rename, column resize

* fix(tables): address PR review comments

- Add doneRef guard to useInlineRename preventing Enter+blur double-fire
- Fix PATCH error handler: return 500 for non-validation errors, fix unreachable logger.error
- Stop click propagation on breadcrumb rename input

* fix(tables): add rows-affected check in renameTable service

Prevents silent no-op when tableId doesn't match any record.

* fix(tables): useMemo deps + placeholder memo initialCharacter check

- Use primitive editingId/editValue in useMemo deps instead of whole
  useInlineRename object (which creates a new ref every render)
- Add initialCharacter comparison to placeholderPropsAreEqual, matching
  the existing pattern in dataRowPropsAreEqual

* fix(tables): address round 2 review comments

- Mirror name validation (regex + max length) in PatchTableSchema so
  validateTableName failures return 400 instead of 500
- Add .returning() + rows-affected check to renameWorkspaceFile,
  matching the renameTable pattern
- Check response.ok before parsing JSON in useRenameWorkspaceFile,
  matching the useRenameTable pattern

* refactor(tables): reuse InlineRenameInput in BreadcrumbSegment

Replace duplicated inline input markup with the shared component.
Eliminates redundant useRef, useEffect, and input boilerplate.

* fix(tables): set doneRef in cancelRename to prevent blur-triggered save

Escape → cancelRename → input unmounts → blur → submitRename would
save instead of canceling. Now cancelRename sets doneRef like
submitRename does, blocking the subsequent blur handler.

* fix(tables): pointercancel cleanup + typed FileConflictError

- Add pointercancel handler to column resize to prevent listener leaks
  when system interrupts the pointer (touch-action override, etc.)
- Replace stringly-typed error.message.includes('already exists') with
  FileConflictError class for refactor-safe 409 status detection

* fix(tables): stable useCallback dep + rename shadowed variable

- Use listRename.startRename (stable ref) instead of whole listRename
  object in handleContextMenuRename deps
- Rename inner 'target' to 'origin' in arrow-key handler to avoid
  shadowing the outer HTMLElement 'target'

* fix(tables): move class below imports, stable submitRename, clear editingCell

- Move FileConflictError below import statements (import-first convention)
- Make submitRename a stable useCallback([]) by reading editingId and
  editValue through refs (matches existing onSaveRef pattern)
- Add setEditingCell(null) to handleEmptyRowClick for symmetry with
  handleCellClick

* feat(tables): persist column widths in table metadata

Column widths now survive navigation and page reloads. On resize-end,
widths are debounced (500ms) and saved to the table's metadata field
via a new PUT /api/table/[tableId]/metadata endpoint. On load, widths
are seeded from the server once via React Query.

* fix type checking for file viewer

* fix(tables): address review feedback — 4 fixes

1. headerRename.onSave now uses the fileId parameter directly instead
   of the selectedFile closure, preventing rename-wrong-file race
2. updateMetadataMutation uses ref pattern matching mutateRef/createRef
3. Type-to-enter filters non-numeric chars for number columns, non-date
   chars for date columns
4. renameValue only passed to actively-renaming ColumnHeaderMenu,
   preserving React.memo for other columns

* fix(tables): position-based gap rows, insert above/below, consistency fixes

- Fix gap row insert shifting: only shift rows when target position is
  occupied, preventing unnecessary displacement of rows below
- Switch to position-based indexing throughout (positionMap, maxPosition)
  instead of array-index for correct sparse position handling
- Add insert row above/below to context menu
- Use CellContent for pending values in PositionGapRows (matching PlaceholderRows)
- Add belowHeader selection overlay logic to PositionGapRows
- Remove unnecessary 500ms debounce on column width persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix cells nav w keyboard

* added preview panel for html, markdown rendering, completed table

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tables): one small tables ting (#3497)

* feat(exa-hosted-key): Restore exa hosted key (#3499)

Co-authored-by: Theodore Li <theo@sim.ai>

* improvement(ui): consistent styling

* styling alignment

* improvements(tables): styling improvements

* improve resizer for file preview for html files

* updated document icon

* fix(credentials): exclude regular login methods from credential sync

* update docs

* upgrade turbo

* improvement: tables, chat

* Fix table column delete

* small table rename bug, files updates not persisting

* Table batch ops

* fix(credentials): block usage at execution layer without perms + fix invites

* feat(hosted-key-services) Add hosted key for multiple services (#3461)

* feat(hosted keys): Implement serper hosted key

* Handle required fields correctly for hosted keys

* Add rate limiting (3 tries, exponential backoff)

* Add custom pricing, switch to exa as first hosted key

* Add telemetry

* Consolidate byok type definitions

* Add warning comment if default calculation is used

* Record usage to user stats table

* Fix unit tests, use cost property

* Include more metadata in cost output

* Fix disabled tests

* Fix spacing

* Fix lint

* Move knowledge cost restructuring away from generic block handler

* Migrate knowledge unit tests

* Lint

* Fix broken tests

* Add user based hosted key throttling

* Refactor hosted key handling. Add optimistic handling of throttling for custom throttle rules.

* Remove research as hosted key. Recommend BYOK if throtttling occurs

* Make adding api keys adjustable via env vars

* Remove vestigial fields from research

* Make billing actor id required for throttling

* Switch to round robin for api key distribution

* Add helper method for adding hosted key cost

* Strip leading double underscores to avoid breaking change

* Lint fix

* Remove falsy check in favor for explicit null check

* Add more detailed metrics for different throttling types

* Fix _costDollars field

* Handle hosted agent tool calls

* Fail loudly if cost field isn't found

* Remove any type

* Fix type error

* Fix lint

* Fix usage log double logging data

* Fix test

* Add browseruse hosted key

* Add firecrawl and serper hosted keys

* feat(hosted key): Add exa hosted key (#3221)

* feat(hosted keys): Implement serper hosted key

* Handle required fields correctly for hosted keys

* Add rate limiting (3 tries, exponential backoff)

* Add custom pricing, switch to exa as first hosted key

* Add telemetry

* Consolidate byok type definitions

* Add warning comment if default calculation is used

* Record usage to user stats table

* Fix unit tests, use cost property

* Include more metadata in cost output

* Fix disabled tests

* Fix spacing

* Fix lint

* Move knowledge cost restructuring away from generic block handler

* Migrate knowledge unit tests

* Lint

* Fix broken tests

* Add user based hosted key throttling

* Refactor hosted key handling. Add optimistic handling of throttling for custom throttle rules.

* Remove research as hosted key. Recommend BYOK if throtttling occurs

* Make adding api keys adjustable via env vars

* Remove vestigial fields from research

* Make billing actor id required for throttling

* Switch to round robin for api key distribution

* Add helper method for adding hosted key cost

* Strip leading double underscores to avoid breaking change

* Lint fix

* Remove falsy check in favor for explicit null check

* Add more detailed metrics for different throttling types

* Fix _costDollars field

* Handle hosted agent tool calls

* Fail loudly if cost field isn't found

* Remove any type

* Fix type error

* Fix lint

* Fix usage log double logging data

* Fix test

---------

Co-authored-by: Theodore Li <teddy@zenobiapay.com>

* Fail fast on cost data not being found

* Add hosted key for google services

* Add hosting configuration and pricing logic for ElevenLabs TTS tools

* Add linkup hosted key

* Add jina hosted key

* Add hugging face hosted key

* Add perplexity hosting

* Add broader metrics for throttling

* Add skill for adding hosted key

* Lint, remove vestigial hosted keys not implemented

* Revert agent changes

* fail fast

* Fix build issue

* Fix build issues

* Fix type error

* Remove byok types that aren't implemented

* Address feedback

* Use default model when model id isn't provided

* Fix cost default issues

* Remove firecrawl error suppression

* Restore original behavior for hugging face

* Add mistral hosted key

* Remove hugging face hosted key

* Fix pricing mismatch is mistral and perplexity

* Add hosted keys for parallel and brand fetch

* Add brandfetch hosted key

* Update types

* Change byok name to parallel_ai

* Add telemetry on unknown models

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* improvement(settings): SSR prefetch, code splitting, dedicated skeletons

* fix: bust browser cache for workspace file downloads

The downloadFile function was using a plain fetch() that honored the
aggressive cache headers, causing newly created files to download empty.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(settings): use emcn Skeleton in extracted skeleton files

* fix(settings): extract shared response mappers to prevent server/client shape drift

Addresses PR review feedback — prefetch.ts duplicated response mapping logic from client hooks. Extracted mapGeneralSettingsResponse and mapUserProfileResponse as shared functions used by both client fetch and server prefetch.

* update byok page

* fix(settings): include theme sync in client-side prefetch queryFn

Hover-based prefetchGeneralSettings now calls syncThemeToNextThemes, matching the useGeneralSettings hook behavior so theme updates aren't missed when prefetch refreshes stale cache.

* fix(byok): use EMCN Input for search field instead of ui Input

Replace @/components/ui Input with the already-imported EmcnInput for design-system consistency.

* fix(byok): use ui Input for search bar to match other settings pages

* fix(settings): use emcn Input for file input in general settings

* improvement(settings): add search bar to skeleton loading states

Skeletons now include the search bar (and action button where applicable) so the layout matches the final component 1:1. Eliminates layout shift when the dynamic chunk loads — search bar area is already reserved by the skeleton.

* fix(settings): align skeleton layouts with actual component structures

- Fix list item gap from 12px to 8px across all skeletons (API keys, custom tools, credentials, MCP)
- Add OAuth icon placeholder to credential skeleton
- Fix credential button group gap from 8px to 4px
- Remove incorrect gap-[4px] from credential-sets text column
- Rebuild debug skeleton to match real layout (description + input/button row)
- Add scrollable wrapper to BYOK skeleton with more representative item count

* chore: lint fixes

* improvement(sidebar): match workspace switcher popover width to sidebar

Use Radix UI's built-in --radix-popover-trigger-width CSS variable
instead of hardcoded 160px so the popover matches the trigger width
and responds to sidebar resizing.

* revert hardcoded ff

* fix: copilot, improvement: tables, mothership

* feat: inline chunk editor and table batch ops with undo/redo (#3504)

* feat: inline chunk editor and table batch operations with undo/redo

Replace modal-based chunk editing/creation with inline editor following
the files tab pattern (state-based view toggle with ResourceHeader).
Add batch update API endpoint, undo/redo support, and Popover-based
context menus for tables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove icons from table context menu PopoverItems

Icons were incorrectly carried over from the DropdownMenu migration.
PopoverItems in this codebase use text-only labels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: restore DropdownMenu for table context menu

The table-level context menu was incorrectly migrated to Popover during
conflict resolution. Only the row-level context menu uses Popover; the
table context menu should remain DropdownMenu with icons, matching the
base branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: bound cross-page chunk navigation polling to max 50 retries

Prevent indefinite polling if page data never loads during
chunk navigation across page boundaries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: navigate to last page after chunk creation for multi-page documents

After creating a chunk, navigate to the last page (where new chunks
append) before selecting it. This prevents the editor from showing
"Loading chunk..." when the new chunk is not on the current page.
The loading state breadcrumb remains as an escape hatch for edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add duplicate rowId validation to BatchUpdateByIdsSchema

Adds a .refine() check to reject duplicate rowIds in batch update
requests, consistent with the positions uniqueness check on batch insert.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review comments

- Fix disableEdit logic: use || instead of && so connector doc chunks
  cannot be edited from context menu (row click still opens viewer)
- Add uniqueness validation for rowIds in BatchUpdateByIdsSchema
- Fix inconsistent bg token: bg-background → bg-[var(--bg)] in Pagination

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove duplicate rowId uniqueness refine on BatchUpdateByIdsSchema

The refine was applied both on the inner updates array and the outer
object. Keep only the inner array refine which is cleaner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address additional PR review comments

- Fix stale rowId after create-row redo: patch undo stack with new row
  ID using patchUndoRowId so subsequent undo targets the correct row
- Fix text color tokens in Pagination: use CSS variable references
  (text-[var(--text-body)], text-[var(--text-secondary)]) instead of
  Tailwind semantic tokens for consistency with the rest of the file

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove dead code and fix type errors in table context menu

Remove unused `onAddData` prop and `isEmptyCell` variable from row context
menu (introduced in PR but never wired to JSX). Fix type errors in
optimistic update spreads by removing unnecessary `as Record<string, unknown>`
casts that lost the RowData type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: prevent false "Saved" status on invalid content and mark fire-and-forget goToPage calls

ChunkEditor.handleSave now throws on empty/oversized content instead of
silently returning, so the parent's catch block correctly sets saveStatus
to 'error'. Also added explicit `void` to unawaited goToPage(1) calls
in filter handlers to signal intentional fire-and-forget.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: handle stale totalPages in handleChunkCreated for new-page edge case

When creating a chunk that spills onto a new page, totalPages in the
closure is stale. Now polls displayChunksRef for the new chunk, and if
not found, checks totalPagesRef for an updated page count and navigates
to the new last page before continuing to poll.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Streaming fix -- need to test more

* Make mothership block use long input instead of prompt input

* improvement(billing): isAnnual metadata + docs updates (#3506)

* improvement(billing): on demand toggling and infinite limits

* store stripe metadata to distinguish annual vs monthly

* udpate docs

* address bugbot

* Add piping

* feat(clean-hosted-keys) Remove eleven labs, browseruse. Tweak firecrawl and mistral key impl (#3503)

* Remove eleven labs, browseruse, and firecrawl

* Remove creditsUsed output

* Add back mistral hosting for mistral blocks

* Add back firecrawl since they queue up concurrent requests

* Fix price calculation, remove agent since its super long running and will clog up queue

* Define hosting per tool

* Remove redundant token finding

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* Update vfs to handle hosted keys

* improvement(tables): fix cell editing flash, batch API docs, and UI polish (#3507)

* fix: show text cursor in chunk editor and ensure textarea fills container

Add cursor-text to the editor wrapper so the whole area shows a text
cursor. Click on empty space focuses the textarea. Changed textarea from
h-full/w-full to flex-1/min-h-0 so it properly fills the flex container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* improvement(tables): fix cell editing flash, add batch API docs, and UI polish

Fix stale-data flash when saving inline cell edits by using TanStack Query's
isPending+variables pattern instead of manual cache writes. Also adds OpenAPI
docs for batch table endpoints, DatePicker support in row modal, duplicate row
in context menu, and styling improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove dead resolveColumnFromEvent callback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: unify paste undo into single create-rows action

Batch-created rows from paste now push one `create-rows` undo entry
instead of N individual `create-row` entries, so a single Ctrl+Z
reverses the entire paste operation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: validate dates in inline editor and displayToStorage

InlineDateEditor now validates computed values via Date.parse before
saving, preventing invalid strings like "hello" from being sent to the
server. displayToStorage now rejects out-of-range month/day values
(e.g. 13/32) instead of producing invalid YYYY-MM-DD strings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: accept ISO date format in inline date editor

Fall back to raw draft input when displayToStorage returns null, so
valid ISO dates like "2024-03-15" pasted or typed directly are
accepted instead of silently discarded. Date.parse still validates
the final value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add ISO date support to displayToStorage and fix picker Escape

displayToStorage now recognizes YYYY-MM-DD input directly, so ISO
dates typed or pasted work correctly for both saving and picker sync.

DatePicker Escape now refocuses the input instead of saving, so the
user can press Escape again to cancel or Enter to confirm — matching
the expected cancel behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove dead paste boundary check

The totalR guard in handlePaste could never trigger since totalR
included pasteRows.length, making targetRow always < totalR.
Remove the unused variable and simplify the selection focus calc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update openapi

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix dysfunctional unique operation in tables

* feat(autosave): files and chunk editor autosave with debounce + refetch  (#3508)

* feat(files): debounced autosave while editing

* address review comments

* more comments

* fix: unique constraint check crash and copilot table initial rows

- Fix TypeError in updateColumnConstraints: db.execute() returns a
  plain array with postgres-js, not { rows: [...] }. The .rows.length
  access always crashed, making "Set unique" completely broken.

- Add initialRowCount: 20 to copilot table creation so tables created
  via chat have the same empty rows as tables created from the UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix signaling

* revert: remove initialRowCount from copilot table creation

Copilot populates its own data after creating a table, so pre-creating
20 empty rows causes data to start at position 21 with empty rows above.
initialRowCount only makes sense for the manual UI creation flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* improvement: chat, workspace header

* chat metadata

* Fix schema mismatch (#3510)

Co-authored-by: Theodore Li <theo@sim.ai>

* Fixes

* fix: manual table creation starts with 1 row, 1 column

Manual tables now create with a single 'name' column and 1 row instead
of 2 columns and 20 rows. Copilot tables remain at 0 rows, 0 columns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: horizontal scroll in embedded table by replacing overflow-hidden with overflow-clip

Cell content spans used Tailwind's `truncate` (overflow: hidden), creating
scroll containers that consumed trackpad wheel events on macOS without
propagating to the actual scroll ancestor. Replaced with overflow-clip
which clips identically but doesn't create a scroll container. Also moved
focus target from outer container to the scroll div for correctness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix tool call ordering

* Fix tests

* feat: add task multi-select, context menu, and subscription UI updates

Add shift-click range selection, cmd/ctrl-click toggle, and right-click
context menu for tasks in sidebar matching workflow/folder patterns.
Update subscription settings tab UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(credentials): autosync behaviour cross workspace (#3511)

* fix(credentials): autosync behaviour cross workspace

* address comments

* fix(api-key-reminder) Add reminder on hosted keys that api key isnt needed (#3512)

* Add reminder on hosted keys that api key isnt needed

* Fix test case

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* improvement: sidebar, chat

* Usage limit

* Plan prompt

* fix(sidebar): workspace header collapse

* fix(sidebar): task navigation

* Subagent tool call persistence

* Don't drop suabgent text

* improvement(ux): streaming

* improvement: thinking

* fix(random): optimized kb connector sync engine, rerenders in tables, files, editors, chat (#3513)

* optimized kb connector sync engine, rerenders in tables, files, editors, chat

* refactor(sidebar): rename onTaskClick to onMultiSelectClick for clarity

Made-with: Cursor

* ack comments, add docsFailed

* feat(email-footer) Add "sent with sim ai" for free users (#3515)

* Add "sent with sim ai" for free users

* Only add prompt injection on free tier

* Add try catch around billing info fetch

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* improvement: modals

* ran migrations

* fix(mothership): fix hardcoded workflow color, tables drag line overflowing

* feat(mothership): file attachment indicators, persistence, and chat input improvements

- Show image thumbnails and file-icon cards above user messages in mothership chat
- Persist file attachment metadata (key, filename, media_type, size) in DB with user messages
- Restore attachments from history via /api/files/serve/ URLs so they survive refresh/navigation
- Unify all chat file inputs to use shared CHAT_ACCEPT_ATTRIBUTE constant
- Fix file thumbnail overflow: use flex-wrap instead of hidden horizontal scroll
- Compact attachment cards in floating workflow chat messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* improvement: search modal

* improvement(usage): free plan to 1000 credits  (#3516)

* improvement(billing): free plan to five dollars

* fix comment

* remove per month terminology from marketing

* generate migration

* remove migration

* add migration back

* feat(workspace): add workspace color changing, consolidate update hooks, fix popover dismiss

- Add workspace color change via context menu, reusing workflow ColorGrid UI
- Consolidate useUpdateWorkspaceName + useUpdateWorkspaceColor into useUpdateWorkspace
- Fix popover hover submenu dismiss by using DismissableLayerBranch with pointerEvents
- Remove passthrough wrapper for export, reuse Workspace type for capturedWorkspaceRef
- Reorder log columns: workflow first, merge date+time into single column

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update oauth cred tool

* fix(diff-controls): fixed positioning for copilot diff controls

* fix(font): added back old font for emcn code editor

* improvement: panel, special tags

* improvement: chat

* improvement: loading and file dropping

* feat(templates): create home templates

* fix(uploads): resolve .md file upload rejection and deduplicate file type utilities

Browsers report empty or application/octet-stream MIME types for .md files,
causing copilot uploads to be rejected. Added resolveFileType() utility that
falls back to extension-based MIME resolution at both client and server
boundaries. Consolidated duplicate MIME mappings into module-level constants,
removed duplicate isImageFileType from copilot module, and replaced hardcoded
ALLOWED_EXTENSIONS with composition from shared validation constants. Also
switched file attachment previews to use shared getDocumentIcon utility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(home): prevent initial view from being scrollable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* autofill fixes

* added back integrations page, reverted secrets page back to old UI

* Fix workspace dropdown getting cut off when sidebar is collapsed

* fix(mothership): lint (#3517)

* fix(mothership): lint

* fix typing

* fix tests

* fix stale query

* fix plan display name

* Feat/add mothership manual workflow runs (#3520)

* Add run and open workflow buttons in workflow preview

* Send log request message after manual workflow run

* Make edges in embedded workflow non-editable

* Change chat to pass in log as additional context

* Revert "Change chat to pass in log as additional context"

This reverts commit e957dffb2f.

* Revert "Send log request message after manual workflow run"

This reverts commit 0fb92751f0.

* Move run and workflow icons to tab bar

* Simplify boolean condition

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* feat(resource-tab-scroll): Allow vertical scrolling to scroll resource tab

* fix(remove-speed-hosted-key) Remove maps speed limit hosted key, it's deprecated (#3521)

Co-authored-by: Theodore Li <theo@sim.ai>

* improvement: home, sidebar

* fix(download-file): render correct file download link for mothership (#3522)

* fix(download-file): render correct file download link for mothership

* Fix uunecessary call

* Use simple strip instead of db lookup and moving behavior

* Make regex strip more strict

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* improvement: schedules, auto-scroll

* fix(settings): navigate back to origin page instead of always going home

Use sessionStorage to store the return URL when entering settings, and
use router.replace for tab switches so history doesn't accumulate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(schedules): release lastQueuedAt lock on all exit paths to prevent stuck schedules

Multiple error/early-return paths in executeScheduleJob and executeJobInline
were exiting without clearing lastQueuedAt, causing the dueFilter to permanently
skip those schedules — resulting in stale "X hours ago" display for nextRunAt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(mothership): inline rename for resource tabs + workspace_file rename tool

- Add double-click inline rename on file and table resource tabs
- Wire useInlineRename + useRenameWorkspaceFile/useRenameTable mutations
- Add rename operation to workspace_file copilot tool (schema, server, router)
- Add knowledge base resource support (type, extraction, rendering, actions)
- Accept optional className on InlineRenameInput for context-specific sizing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* revert: remove inline rename UI from resource tabs

Keep the workspace_file rename tool for the mothership agent.
Only the UI-side inline rename (double-click tabs) is removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(mothership): knowledge base resource extraction + Resource/ResourceTable refactor

- Extract KB resources from knowledge subagent respond format (knowledge_bases array)
- Add knowledge_base tool to RESOURCE_TOOL_NAMES and TOOL_UI_METADATA
- Extract ResourceTable as independently composable memoized component
- Move contentOverride/overlay to Resource shell level (not table primitive)
- Remove redundant disableHeaderSort and loadingRows props
- Rename internal sort state for clarity (sort → internalSort, sortOverride → externalSort)
- Export ResourceTable and ResourceTableProps from barrel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(logs) Run workflows client side in mothership to transmit logs (#3529)

* Run workflows client side in mothership to transmit logs

* Initialize set as constant, prevent duplicate execution

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* fix(import) fix missing file

* fix(resource): Hide resources that have been deleted (#3528)

* Hide resources that have been deleted

* Handle table, workflow not found

* Add animation to prevent flash when previous resource was deleted

* Fix animation playing on every switch

* Run workflows client side in mothership to transmit logs

* Fix race condition for animation

* Use shared workflow tool util file

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* fix: chat scrollbar on sidebar collapse/open

* edit existing workflow should bring up artifact

* fix(agent) subagent and main agent text being merged without spacing

* feat(mothership): remove resource-level delete tools from copilot

Remove delete operations for workflows, folders, tables, and files
from the mothership copilot to prevent destructive actions via AI.
Row-level and column-level deletes are preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: stop sidebar from auto-collapsing when resource panel appears (#3540)

The sidebar was forcibly collapsed whenever a resource (e.g. workflow)
first appeared in the resource panel during a task. This was disruptive
on larger screens where users want to keep both the sidebar and resource
panel visible simultaneously.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mothership): insert copilot-created workflows at top of list (#3537)

* feat(mothership): remove resource-level delete tools from copilot

Remove delete operations for workflows, folders, tables, and files
from the mothership copilot to prevent destructive actions via AI.
Row-level and column-level deletes are preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mothership): insert copilot-created workflows at top of list

* fix(mothership): server-side top-insertion sort order and deduplicate registry logic

* fix(mothership): include folder sort orders when computing top-insertion position

* fix(mothership): use getNextWorkflowColor instead of hardcoded color

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(stop) Add stop of motehership ran workflows, persist stop messages (#3538)

* Connect play stop workflow in embedded view to workflow

* Fix stop not actually stoping workflow

* Fix ui not showing stopped by user

* Lint fix

* Plumb cancellation through system

* Stopping mothership chat stops workflow

* Remove extra fluff

* Persist blocks on cancellation

* Add root level stopped by user

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* fix(autolayout): targetted autolayout heuristic restored (#3536)

* fix(autolayout): targetted autolayout heuristic restored

* fix autolayout boundary cases

* more fixes

* address comments

* on conflict updates

* address more comments

* fix relative position scope

* fix tye omission

* address bugbot comment

* Credential tags

* Credential id field

* feat(mothership): server-persisted unread task indicators via SSE (#3549)

* feat(mothership): server-persisted unread task indicators via SSE

Replace fragile client-side polling + timer-based green flash with
server-persisted lastSeenAt semantics, real-time SSE push via Redis
pub/sub, and dot overlay UI on the Blimp icon.

- Add lastSeenAt column to copilotChats for server-persisted read state
- Add Redis/local pub/sub singleton for task status events (started,
  completed, created, deleted, renamed)
- Add SSE endpoint (GET /api/mothership/events) with heartbeat and
  workspace-scoped filtering
- Add mark-read endpoint (POST /api/mothership/chats/read)
- Publish SSE events from chat, rename, delete, and auto-title handlers
- Add useTaskEvents hook for client-side SSE subscription
- Add useMarkTaskRead mutation with optimistic update
- Replace timer logic in sidebar with TaskStatus state machine
  (running/unread/idle) and dot overlay using brand color variables
- Mark tasks read on mount and stream completion in home page
- Fix security: add userId check to delete WHERE clause
- Fix: bump updatedAt on stream completion
- Fix: set lastSeenAt on rename to prevent false-positive unread

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review feedback

- Return 404 when delete finds no matching chat (was silent no-op)
- Move log after ownership check so it only fires on actual deletion
- Publish completed SSE event from stop route so sidebar dot clears on abort

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: backfill last_seen_at in migration to prevent false unread dots

Existing rows would have last_seen_at = NULL after migration, causing
all past completed tasks to show as unread. Backfill sets last_seen_at
to updated_at for all existing rows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: timestamp mismatch on task creation + wasSendingRef leak across navigation

- Pass updatedAt explicitly alongside lastSeenAt on chat creation so
  both use the same JS timestamp (DB defaultNow() ran later, causing
  updatedAt > lastSeenAt → false unread)
- Reset wasSendingRef when chatId changes to prevent a stale true
  from task A triggering a redundant markRead on task B

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: mark-read fires for inline-created chats + encode workspaceId in SSE URL

Expose resolvedChatId from useChat so home.tsx can mark-read even when
chatId prop stays undefined after replaceState URL update. Also
URL-encode workspaceId in EventSource URL as a defensive measure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: auto-focus home input on initial view + fix sidebar task click handling

Auto-focus the textarea when the initial home view renders. Also fix
sidebar task click to always call onMultiSelectClick so selection state
stays consistent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: auto-title sets lastSeenAt + move started event inside DB guard

Auto-title now sets both updatedAt and lastSeenAt (matching the rename
route pattern) to prevent false-positive unread dots. Also move the
'started' SSE event inside the if(updated) guard so it only fires when
the DB update actually matched a row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* modified tasks multi select to be just like workflows

* fix

* refactor: extract generic pub/sub and SSE factories + fixes

- Extract createPubSubChannel factory (lib/events/pubsub.ts) to eliminate
  duplicated Redis/EventEmitter boilerplate between task and MCP pub/sub
- Extract createWorkspaceSSE factory (lib/events/sse-endpoint.ts) to share
  auth, heartbeat, and cleanup logic across SSE endpoints
- Fix auto-title race suppressing unread status by removing updatedAt/lastSeenAt
  from title-only DB update
- Fix wheel event listener leak in ResourceTabs (RefCallback cleanup was silently
  discarded)
- Fix getFullSelection() missing taskIds (inconsistent with hasAnySelection)
- Deduplicate SSE_RESPONSE_HEADERS to spread from shared SSE_HEADERS
- Hoist isSttAvailable to module-level constant to avoid per-render IIFE

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(logs): add workflow trigger type for sub-workflow executions (#3554)

* feat(logs): add workflow trigger type for sub-workflow executions

* fix(logs): align workflow filter color with blue-secondary badge variant

* feat(tab) allow user to control resource tabs

* Make resources persist to backend

* Use colored squares for workflows

* Add click and drag functionality to resource

* Fix expanding panel logic

* Reduce duplication, reading resource also opens up resource panel

* Move resource dropdown to own file

* Handle renamed resources

* Clicking already open tab should just switch to tab

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* Fix new resource tab button not appearing on tasks

* improvement(ui): dropdown menus, icons, globals

* improvement: notifications, terminal, globals

* reverted task logic

* feat(context) pass resource tab as context (#3555)

* feat(context) add currenttly open resource file to context for agent

* Simplify resource resolution

* Skip initialize vfs

* Restore ff

* Add back try catch

* Remove redundant code

* Remove json serialization/deserialization loop

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* Feat(references) add at to reference sim resources(#3560)


* feat(chat) add at sign

* Address bugbot issues

* Remove extra chatcontext defs

* Add table and file to schema

* Add icon to chip for files

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* improvement(refactor): move to soft deletion of resources + reliability improvements (#3561)

* improvement(deletion): migrate to soft deletion of resources

* progress

* scoping fixes

* round of fixes

* deduplicated name on workflow import

* fix tests

* add migration

* cleanup dead code

* address bugbot comments

* optimize query

* feat(sim-mailer): email inbox for mothership with chat history and plan gating (#3558)

* feat(sim-mailer): email inbox for mothership with chat history and plan gating

* revert hardcoded ff

* fix(inbox): address PR review comments - plan enforcement, idempotency, webhook auth

- Enforce Max plan at API layer: hasInboxAccess() now checks subscription tier (>= 25k credits or enterprise)
- Add idempotency guard to executeInboxTask() to prevent duplicate emails on Trigger.dev retries
- Add AGENTMAIL_WEBHOOK_SECRET env var for webhook signature verification (Bearer token)

* improvement(inbox): harden security and efficiency from code audit

- Use crypto.timingSafeEqual for webhook secret comparison (prevents timing attacks)
- Atomic claim in executor: WHERE status='received' prevents duplicate processing on retries
- Parallelize hasInboxAccess + getUserEntityPermissions in all API routes (reduces latency)
- Truncate email body at webhook insertion (50k char limit, prevents unbounded DB storage)
- Harden escapeAttr with angle bracket and single quote escaping
- Rename use-inbox.ts to inbox.ts (matches hooks/queries/ naming convention)

* fix(inbox): replace Bearer token auth with proper Svix HMAC-SHA256 webhook verification

- Use per-workspace webhook secret from DB instead of global env var
- Verify AgentMail/Svix signatures: HMAC-SHA256 over svix-id.timestamp.body
- Timing-safe comparison via crypto.timingSafeEqual
- Replay protection via timestamp tolerance (5 min window)
- Join mothershipInboxWebhook in workspace lookup (zero additional DB calls)
- Remove dead AGENTMAIL_WEBHOOK_SECRET env var
- Select only needed workspace columns in webhook handler

* fix(inbox): require webhook secret — reject requests when secret is missing

Previously, if the webhook secret was missing from the DB (corrupted state),
the handler would skip verification entirely and process the request
unauthenticated. Now all three conditions are hard requirements: secret must
exist in DB, Svix headers must be present, and signature must verify.

* fix(inbox): address second round of PR review comments

- Exclude rejected tasks from rate limit count to prevent DoS via spam
- Strip raw HTML from LLM output before marked.parse to prevent XSS in emails
- Track responseSent flag to prevent duplicate emails when DB update fails after send

* fix(inbox): address third round of PR review comments

- Use dynamic isHosted from feature-flags instead of hardcoded true
- Atomic JSON append for chat message persistence (eliminates read-modify-write race)
- Handle cutIndex === 0 in stripQuotedReply (body starts with quote)
- Clean up orphan mothershipInboxWebhook row on enableInbox rollback
- Validate status query parameter against enum in tasks API

* fix(inbox): validate cursor param, preserve code blocks in HTML stripping

- Validate cursor date before using in query (return 400 for invalid)
- Split on fenced code blocks before stripping HTML tags to preserve
  code examples in email responses

* fix(inbox): return 500 on webhook server errors to enable Svix retries

* fix(inbox): remove isHosted guard from hasInboxAccess — feature flag is sufficient

* fix(inbox): prevent double-enable from deleting webhook secret row

* fix(inbox): null-safe stripThinkingTags, encode URL params, surface remove-sender errors

- Guard against null result.content in stripThinkingTags
- Use encodeURIComponent on all AgentMail API path parameters
- Surface handleRemoveSender errors to the user instead of swallowing

* improvement(inbox): remove unused types, narrow SELECT queries, fix optimistic ID collision

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(inbox): add keyboard accessibility to clickable task rows

* fix(inbox): use Svix library for webhook verification, fix responseSent flag, prevent inbox enumeration

- Replace manual HMAC-SHA256 verification with official Svix library per AgentMail docs
- Fix responseSent flag: only set true when email delivery actually succeeds
- Return consistent 401 for unknown inbox and bad signature to prevent enumeration
- Make AgentMailInbox.organization_id optional to match API docs

* chore(db): rebase inbox migration onto feat/mothership-copilot (0172 → 0173)

Sync schema with target branch and regenerate migration as 0173
to avoid conflicts with 0172_silky_magma on feat/mothership-copilot.

* fix(db): rebase inbox migration to 0173 after feat/mothership-copilot divergence

Target branch added 0172_silky_magma, so our inbox migration is now 0173_youthful_stryfe.

* fix(db): regenerate inbox migration after rebase on feat/mothership-copilot

* fix(inbox): case-insensitive email match and sanitize javascript: URIs in email HTML

- Use lower() in isSenderAllowed SQL to match workspace members regardless
  of email case stored by auth provider
- Strip javascript:, vbscript:, and data: URIs from marked HTML output to
  prevent XSS in outbound email responses

* fix(inbox): case-insensitive email match in resolveUserId

Consistent with the isSenderAllowed fix — uses lower() so mixed-case
stored emails match correctly, preventing silent fallback to workspace owner.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Kb args

* refactor(resource): remove logs-specific escape hatches from Resource abstraction

Logs now composes ResourceHeader + ResourceOptionsBar + ResourceTable directly
instead of using Resource with contentOverride/overlay escape hatches. Removes
contentOverride, onLoadMore, hasMore, isLoadingMore from ResourceProps. Adds
ColumnOption to barrel export and fixes table.tsx internal import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(sim-mailer): download email attachments and pass to LLM as multimodal content

Attachments were only passed as metadata text in the email body. Now downloads
actual file bytes from AgentMail, converts via createFileContent (same path as
interactive chat), and sends as fileAttachments to the orchestrator. Also
parallelizes attachment fetching with workspace context loading, and downloads
multiple attachments concurrently via Promise.allSettled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(connector): add Gmail knowledge base connector with thread-based sync and filtering

Syncs email threads from Gmail into knowledge bases with configurable filters:
label scoping, date range presets, promotions/social exclusion, Gmail search
syntax support, and max thread caps to keep KB size manageable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(connector): add Outlook knowledge base connector with conversation grouping and filtering

Syncs email conversations from Outlook/Office 365 via Microsoft Graph API.
Groups messages by conversationId into single documents. Configurable filters:
folder selection, date range presets, Focused Inbox, KQL search syntax, and
max conversation caps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* cleanup resource definition

* feat(connectors): add 8 knowledge base connectors — Zendesk, Intercom, ServiceNow, Google Sheets, Microsoft Teams, Discord, Google Calendar, Reddit

Each connector syncs documents into knowledge bases with configurable filtering:

- Zendesk: Help Center articles + support tickets with status/locale filters
- Intercom: Articles + conversations with state filtering
- ServiceNow: KB articles + incidents with state/priority/category filters
- Google Sheets: Spreadsheet tabs as LLM-friendly row-by-row documents
- Microsoft Teams: Channel messages (Slack-like pattern) via Graph API
- Discord: Channel messages with bot token auth
- Google Calendar: Events with date range presets and attendee metadata
- Reddit: Subreddit posts with top comments, sort/time filters

All connectors validated against official API docs with bug fixes applied.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(inbox): fetch real attachment binary from presigned URL and persist for chat display

The AgentMail attachment endpoint returns JSON metadata with a download_url,
not raw binary. We were base64-encoding the JSON text and sending it to the
LLM, causing provider rejection. Now we parse the metadata, fetch the actual
file from the presigned URL, upload it to copilot storage, and persist it on
the chat message so images render inline with previews.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* added agentmail domain for mailer

* added docs for sim mailer

* fix(resource) handle resource deletion  deletion (#3568)

* Add handle dragging tab to input chat

* Add back delete tools

* Handle deletions properly with resources view

* Fix lint

* Add permisssions checking

* Skip resource_added event when resource is deleted

* Pass workflow id as context

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* update docs styling, add delete confirmation on inbox

* Fix fast edit route

* updated docs styling, added FAQs, updated content

* upgrade turbo

* fix(knowledge) use consistent empty state for documents page

Replace the centered "No documents yet" text with the standard Resource
table empty state (column headers + create row), matching all other
resource pages. Move "Upload documents" from header action to table
create row as "New documents".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(notifications): polish modal styling, credential display, and trigger filters (#3571)

* fix(notifications): polish modal styling, credential display, and trigger filters

- Show credential display name instead of raw account ID in Slack account selector
- Fix label styling to use default Label component (text-primary) for consistency
- Fix modal body spacing with proper top padding after tab bar
- Replace list-card skeleton with form-field skeleton matching actual layout
- Replace custom "Select a Slack account first" box with disabled Combobox (dependsOn pattern)
- Use proper Label component in WorkflowSelector with consistent gap spacing
- Add overflow badge pattern (slice + +N) to level and trigger filter badges
- Use dynamic trigger options from getTriggerOptions() instead of hardcoded CORE_TRIGGER_TYPES
- Relax API validation to accept integration trigger types (z.string instead of z.enum)
- Deduplicate account rows from credential leftJoin in accounts API
- Extract getTriggerOptions() to module-level constants to avoid per-render calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(notifications): address PR review feedback

- Restore accountId in displayName fallback chain (credentialDisplayName || accountId || providerId)
- Add .default([]) to triggerFilter in create schema to preserve backward compatibility
- Treat empty triggerFilter as "match all" in notification matching logic
- Remove unreachable overflow badge for levelFilter (only 2 possible values)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(settings): add spacing to Sim Keys toggle and replace Sim Mailer icon with Send

Add 24px top margin to the "Allow personal Sim keys" toggle so it doesn't
sit right below the empty state. Replace the Mail envelope icon for Sim
Mailer with a new Send (paper plane) icon matching the emcn icon style.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* standardize back buttons in settings

* feat(restore) Add restore endpoints and ui (#3570)

* Add restore endpoints and ui

* Derive toast from notification

* Auth user if workspaceid not found

* Fix recently deleted ui

* Add restore error toast

* Fix deleted at timestamp mismatch

---------

Co-authored-by: Theodore Li <theo@sim.ai>

* fix type errors

* Lint

* improvements: ui/ux around mothership

* reactquery best practices, UI alignment in restore

* clamp logs panel

* subagent thinking text

* fix build, speedup tests by up to 40%

* Fix fast edit

* Add download file shortcut on mothership file view

* fix: SVG file support in mothership chat and file serving

- Send SVGs as document/text-xml to Claude instead of unsupported
  image/svg+xml, so the mothership can actually read SVG content
- Serve SVGs inline with proper content type and CSP sandbox so
  chat previews render correctly
- Add SVG preview support in file viewer (sandboxed iframe)
- Derive IMAGE_MIME_TYPES from MIME_TYPE_MAPPING to reduce duplication
- Add missing webp to contentTypeMap, SAFE_INLINE_TYPES, binaryExtensions
- Consolidate PREVIEWABLE_EXTENSIONS into preview-panel exports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace image/* wildcard with explicit supported types in file picker

The image/* accept attribute allowed users to select BMP, TIFF, HEIC,
and other image types that are rejected server-side. Replace with the
exact set of supported image MIME types and extensions to match the
copilot upload validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Context tags

* Fix lint

* improvement: chat and terminal

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Theodore Li <teddy@zenobiapay.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Theodore Li <theodoreqili@gmail.com>
Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-13 21:02:08 -07:00
129 changed files with 2699 additions and 1293 deletions

View File

@@ -117,6 +117,8 @@ export const {service}Connector: ConnectorConfig = {
The add-connector modal renders these automatically — no custom UI needed.
Three field types are supported: `short-input`, `dropdown`, and `selector`.
```typescript
// Text input
{
@@ -141,6 +143,136 @@ The add-connector modal renders these automatically — no custom UI needed.
}
```
## Dynamic Selectors (Canonical Pairs)
Use `type: 'selector'` to fetch options dynamically from the existing selector registry (`hooks/selectors/registry.ts`). Selectors are always paired with a manual fallback input using the **canonical pair** pattern — a `selector` field (basic mode) and a `short-input` field (advanced mode) linked by `canonicalParamId`.
The user sees a toggle button (ArrowLeftRight) to switch between the selector dropdown and manual text input. On submit, the modal resolves each canonical pair to the active mode's value, keyed by `canonicalParamId`.
### Rules
1. **Every selector field MUST have a canonical pair** — a corresponding `short-input` (or `dropdown`) field with the same `canonicalParamId` and `mode: 'advanced'`.
2. **`required` must be set identically on both fields** in a pair. If the selector is required, the manual input must also be required.
3. **`canonicalParamId` must match the key the connector expects in `sourceConfig`** (e.g. `baseId`, `channel`, `teamId`). The advanced field's `id` should typically match `canonicalParamId`.
4. **`dependsOn` references the selector field's `id`**, not the `canonicalParamId`. The modal propagates dependency clearing across canonical siblings automatically — changing either field in a parent pair clears dependent children.
### Selector canonical pair example (Airtable base → table cascade)
```typescript
configFields: [
// Base: selector (basic) + manual (advanced)
{
id: 'baseSelector',
title: 'Base',
type: 'selector',
selectorKey: 'airtable.bases', // Must exist in hooks/selectors/registry.ts
canonicalParamId: 'baseId',
mode: 'basic',
placeholder: 'Select a base',
required: true,
},
{
id: 'baseId',
title: 'Base ID',
type: 'short-input',
canonicalParamId: 'baseId',
mode: 'advanced',
placeholder: 'e.g. appXXXXXXXXXXXXXX',
required: true,
},
// Table: selector depends on base (basic) + manual (advanced)
{
id: 'tableSelector',
title: 'Table',
type: 'selector',
selectorKey: 'airtable.tables',
canonicalParamId: 'tableIdOrName',
mode: 'basic',
dependsOn: ['baseSelector'], // References the selector field ID
placeholder: 'Select a table',
required: true,
},
{
id: 'tableIdOrName',
title: 'Table Name or ID',
type: 'short-input',
canonicalParamId: 'tableIdOrName',
mode: 'advanced',
placeholder: 'e.g. Tasks',
required: true,
},
// Non-selector fields stay as-is
{ id: 'maxRecords', title: 'Max Records', type: 'short-input', ... },
]
```
### Selector with domain dependency (Jira/Confluence pattern)
When a selector depends on a plain `short-input` field (no canonical pair), `dependsOn` references that field's `id` directly. The `domain` field's value maps to `SelectorContext.domain` automatically via `SELECTOR_CONTEXT_FIELDS`.
```typescript
configFields: [
{
id: 'domain',
title: 'Jira Domain',
type: 'short-input',
placeholder: 'yoursite.atlassian.net',
required: true,
},
{
id: 'projectSelector',
title: 'Project',
type: 'selector',
selectorKey: 'jira.projects',
canonicalParamId: 'projectKey',
mode: 'basic',
dependsOn: ['domain'],
placeholder: 'Select a project',
required: true,
},
{
id: 'projectKey',
title: 'Project Key',
type: 'short-input',
canonicalParamId: 'projectKey',
mode: 'advanced',
placeholder: 'e.g. ENG, PROJ',
required: true,
},
]
```
### How `dependsOn` maps to `SelectorContext`
The connector selector field builds a `SelectorContext` from dependency values. For the mapping to work, each dependency's `canonicalParamId` (or field `id` for non-canonical fields) must exist in `SELECTOR_CONTEXT_FIELDS` (`lib/workflows/subblocks/context.ts`):
```
oauthCredential, domain, teamId, projectId, knowledgeBaseId, planId,
siteId, collectionId, spreadsheetId, fileId, baseId, datasetId, serviceDeskId
```
### Available selector keys
Check `hooks/selectors/types.ts` for the full `SelectorKey` union. Common ones for connectors:
| SelectorKey | Context Deps | Returns |
|-------------|-------------|---------|
| `airtable.bases` | credential | Base ID + name |
| `airtable.tables` | credential, `baseId` | Table ID + name |
| `slack.channels` | credential | Channel ID + name |
| `gmail.labels` | credential | Label ID + name |
| `google.calendar` | credential | Calendar ID + name |
| `linear.teams` | credential | Team ID + name |
| `linear.projects` | credential, `teamId` | Project ID + name |
| `jira.projects` | credential, `domain` | Project key + name |
| `confluence.spaces` | credential, `domain` | Space key + name |
| `notion.databases` | credential | Database ID + name |
| `asana.workspaces` | credential | Workspace GID + name |
| `microsoft.teams` | credential | Team ID + name |
| `microsoft.channels` | credential, `teamId` | Channel ID + name |
| `webflow.sites` | credential | Site ID + name |
| `outlook.folders` | credential | Folder ID + name |
## ExternalDocument Shape
Every document returned from `listDocuments`/`getDocument` must include:
@@ -287,6 +419,12 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = {
- [ ] **Auth configured correctly:**
- OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts`
- API key: `auth.label` and `auth.placeholder` set appropriately
- [ ] **Selector fields configured correctly (if applicable):**
- Every `type: 'selector'` field has a canonical pair (`short-input` or `dropdown` with same `canonicalParamId` and `mode: 'advanced'`)
- `required` is identical on both fields in each canonical pair
- `selectorKey` exists in `hooks/selectors/registry.ts`
- `dependsOn` references selector field IDs (not `canonicalParamId`)
- Dependency `canonicalParamId` values exist in `SELECTOR_CONTEXT_FIELDS`
- [ ] `listDocuments` handles pagination and computes content hashes
- [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative)
- [ ] `metadata` includes source-specific data for tag mapping

View File

@@ -1,18 +1,7 @@
'use client'
import { memo } from 'react'
import { memo, useEffect, useState } from 'react'
/** Shared corner radius from Figma export for all decorative rects. */
const RX = '2.59574'
const ENTER_STAGGER = 0.06
const ENTER_DURATION = 0.3
const EXIT_STAGGER = 0.12
const EXIT_DURATION = 0.5
const INITIAL_HOLD = 3000
const HOLD_BETWEEN = 3000
const TRANSITION_PAUSE = 400
interface BlockRect {
opacity: number
width: string
@@ -23,8 +12,6 @@ interface BlockRect {
transform?: string
}
type AnimState = 'visible' | 'exiting' | 'hidden'
const RECTS = {
topRight: [
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
@@ -67,76 +54,33 @@ const RECTS = {
fill: '#FA4EDF',
},
],
left: [
{
opacity: 0.6,
width: '34.240',
height: '33.725',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0 0)',
},
{
opacity: 0.6,
width: '16.8626',
height: '68.480',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.727 0)',
},
bottomLeft: [
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
{
opacity: 1,
x: '51.6188',
y: '16.8626',
width: '16.8626',
height: '16.8626',
fill: '#FA4EDF',
transform: 'matrix(-1 0 0 1 33.727 17.378)',
},
{
opacity: 0.6,
width: '16.8626',
height: '33.986',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0 51.616)',
},
{
opacity: 0.6,
width: '16.8626',
height: '140.507',
fill: '#00F701',
transform: 'matrix(-1 0 0 1 33.986 85.335)',
},
{
opacity: 0.4,
x: '17.119',
y: '136.962',
width: '34.240',
height: '16.8626',
fill: '#FFCC02',
transform: 'rotate(-90 17.119 136.962)',
},
{
opacity: 1,
x: '17.119',
y: '136.962',
width: '16.8626',
height: '16.8626',
fill: '#FFCC02',
transform: 'rotate(-90 17.119 136.962)',
},
{
opacity: 0.5,
width: '34.240',
height: '33.725',
fill: '#00F701',
transform: 'matrix(0 1 1 0 0.257 153.825)',
fill: '#2ABBF8',
},
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#00F701' },
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#00F701' },
{
opacity: 1,
x: '123.6484',
y: '16.8626',
width: '16.8626',
height: '16.8626',
fill: '#00F701',
transform: 'matrix(0 1 1 0 0.257 153.825)',
},
],
right: [
bottomRight: [
{
opacity: 0.6,
width: '16.8626',
@@ -175,68 +119,33 @@ const RECTS = {
{
opacity: 0.6,
width: '16.8626',
height: '33.726',
fill: '#FA4EDF',
transform: 'matrix(0 1 1 0 0.012 68.510)',
},
{
opacity: 0.6,
width: '16.8626',
height: '102.384',
height: '34.24',
fill: '#2ABBF8',
transform: 'matrix(-1 0 0 1 33.787 102.384)',
transform: 'matrix(-1 0 0 1 33.787 68)',
},
{
opacity: 0.4,
x: '17.131',
y: '153.859',
width: '34.241',
height: '16.8626',
fill: '#00F701',
transform: 'rotate(-90 17.131 153.859)',
},
{
opacity: 1,
x: '17.131',
y: '153.859',
width: '16.8626',
height: '16.8626',
fill: '#00F701',
transform: 'rotate(-90 17.131 153.859)',
fill: '#1A8FCC',
transform: 'matrix(-1 0 0 1 33.787 85)',
},
],
} as const satisfies Record<string, readonly BlockRect[]>
type Position = keyof typeof RECTS
function enterTime(pos: Position): number {
return (RECTS[pos].length - 1) * ENTER_STAGGER + ENTER_DURATION
}
function exitTime(pos: Position): number {
return (RECTS[pos].length - 1) * EXIT_STAGGER + EXIT_DURATION
}
interface BlockGroupProps {
width: number
height: number
viewBox: string
rects: readonly BlockRect[]
animState: AnimState
globalOpacity: number
}
const GLOBAL_OPACITY = 0.55
const BlockGroup = memo(function BlockGroup({
width,
height,
viewBox,
rects,
animState,
globalOpacity,
}: BlockGroupProps) {
const isVisible = animState === 'visible'
const isExiting = animState === 'exiting'
}: {
width: number
height: number
viewBox: string
rects: readonly BlockRect[]
}) {
return (
<svg
width={width}
@@ -245,7 +154,7 @@ const BlockGroup = memo(function BlockGroup({
fill='none'
xmlns='http://www.w3.org/2000/svg'
className='h-auto w-full'
style={{ opacity: globalOpacity }}
style={{ opacity: GLOBAL_OPACITY }}
>
{rects.map((r, i) => (
<rect
@@ -257,114 +166,29 @@ const BlockGroup = memo(function BlockGroup({
rx={RX}
fill={r.fill}
transform={r.transform}
style={{
opacity: isVisible ? r.opacity : 0,
transition: `opacity ${isExiting ? EXIT_DURATION : ENTER_DURATION}s ease ${
isVisible ? i * ENTER_STAGGER : isExiting ? i * EXIT_STAGGER : 0
}s`,
}}
opacity={r.opacity}
/>
))}
</svg>
)
})
function useGroupState(): [AnimState, (s: AnimState) => void] {
return useState<AnimState>('visible')
}
function useBlockCycle() {
const [topRight, setTopRight] = useGroupState()
const [left, setLeft] = useGroupState()
const [right, setRight] = useGroupState()
useEffect(() => {
if (typeof window !== 'undefined' && !window.matchMedia('(min-width: 1024px)').matches) return
const cancelled = { current: false }
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
async function exit(setter: (s: AnimState) => void, pos: Position, pauseAfter: number) {
if (cancelled.current) return
setter('exiting')
await wait(exitTime(pos) * 1000)
if (cancelled.current) return
setter('hidden')
await wait(pauseAfter)
}
async function enter(setter: (s: AnimState) => void, pos: Position, pauseAfter: number) {
if (cancelled.current) return
setter('visible')
await wait(enterTime(pos) * 1000 + pauseAfter)
}
const run = async () => {
await wait(INITIAL_HOLD)
while (!cancelled.current) {
await exit(setTopRight, 'topRight', TRANSITION_PAUSE)
await exit(setLeft, 'left', HOLD_BETWEEN)
await enter(setLeft, 'left', TRANSITION_PAUSE)
await enter(setTopRight, 'topRight', TRANSITION_PAUSE)
await exit(setRight, 'right', HOLD_BETWEEN)
await enter(setRight, 'right', HOLD_BETWEEN)
}
}
run()
return () => {
cancelled.current = true
}
}, [])
return { topRight, left, right } as const
}
/**
* Ambient animated block decorations for the docs layout.
* Adapts the landing page's colorful block patterns with slightly reduced
* opacity and the same staggered enter/exit animation cycle.
*/
export function AnimatedBlocks() {
const states = useBlockCycle()
return (
<div
className='pointer-events-none fixed inset-0 z-0 hidden overflow-hidden lg:block'
aria-hidden='true'
>
<div className='absolute top-[93px] right-0 w-[calc(140px+10.76vw)] max-w-[295px]'>
<BlockGroup
width={295}
height={34}
viewBox='0 0 295 34'
rects={RECTS.topRight}
animState={states.topRight}
globalOpacity={0.75}
/>
<BlockGroup width={295} height={34} viewBox='0 0 295 34' rects={RECTS.topRight} />
</div>
<div className='-translate-y-1/2 absolute top-[50%] left-0 w-[calc(16px+1.25vw)] max-w-[34px] scale-x-[-1]'>
<BlockGroup
width={34}
height={226}
viewBox='0 0 34 226.021'
rects={RECTS.left}
animState={states.left}
globalOpacity={0.75}
/>
<div className='-left-24 absolute bottom-0 w-[calc(140px+10.76vw)] max-w-[295px] rotate-180'>
<BlockGroup width={295} height={34} viewBox='0 0 295 34' rects={RECTS.bottomLeft} />
</div>
<div className='-translate-y-1/2 absolute top-[50%] right-0 w-[calc(16px+1.25vw)] max-w-[34px]'>
<BlockGroup
width={34}
height={205}
viewBox='0 0 34 204.769'
rects={RECTS.right}
animState={states.right}
globalOpacity={0.75}
/>
<div className='-bottom-2 absolute right-0 w-[calc(16px+1.25vw)] max-w-[34px]'>
<BlockGroup width={34} height={102} viewBox='0 0 34 102' rects={RECTS.bottomRight} />
</div>
</div>
)

View File

@@ -25,7 +25,7 @@ function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
}}
>
{Array.from({ length: cols * rows }, (_, i) => (
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#2A2A2A]' />
))}
</div>
)
@@ -268,13 +268,13 @@ export default function Collaboration() {
collaboration
</h2>
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
Grab your team. Build agents together <br /> in real-time inside your workspace.
</p>
<Link
href='/signup'
className='group/cta mt-[12px] inline-flex h-[32px] cursor-none items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
className='group/cta mt-[12px] inline-flex h-[32px] cursor-none items-center gap-[6px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Build together
<span className='relative h-[10px] w-[10px] shrink-0'>

View File

@@ -0,0 +1,113 @@
'use client'
import { type SVGProps, useRef } from 'react'
import { motion, useInView } from 'framer-motion'
import { ChevronDown } from '@/components/emcn'
import { Database, File, Library, Table } from '@/components/emcn/icons'
import {
AnthropicIcon,
GeminiIcon,
GmailIcon,
GroqIcon,
HubspotIcon,
OpenAIIcon,
SalesforceIcon,
SlackIcon,
xAIIcon,
} from '@/components/icons'
interface IconEntry {
key: string
icon: React.ComponentType<SVGProps<SVGSVGElement>>
label: string
top: string
left: string
color?: string
}
const SCATTERED_ICONS: IconEntry[] = [
{ key: 'slack', icon: SlackIcon, label: 'Slack', top: '8%', left: '14%' },
{ key: 'openai', icon: OpenAIIcon, label: 'OpenAI', top: '8%', left: '44%' },
{ key: 'anthropic', icon: AnthropicIcon, label: 'Anthropic', top: '10%', left: '78%' },
{ key: 'gmail', icon: GmailIcon, label: 'Gmail', top: '24%', left: '90%' },
{ key: 'salesforce', icon: SalesforceIcon, label: 'Salesforce', top: '28%', left: '6%' },
{ key: 'table', icon: Table, label: 'Tables', top: '22%', left: '30%' },
{ key: 'xai', icon: xAIIcon, label: 'xAI', top: '26%', left: '66%' },
{ key: 'hubspot', icon: HubspotIcon, label: 'HubSpot', top: '55%', left: '4%', color: '#FF7A59' },
{ key: 'database', icon: Database, label: 'Database', top: '74%', left: '68%' },
{ key: 'file', icon: File, label: 'Files', top: '70%', left: '18%' },
{ key: 'gemini', icon: GeminiIcon, label: 'Gemini', top: '58%', left: '86%' },
{ key: 'logs', icon: Library, label: 'Logs', top: '86%', left: '44%' },
{ key: 'groq', icon: GroqIcon, label: 'Groq', top: '90%', left: '82%' },
]
const EXPLODE_STAGGER = 0.04
const EXPLODE_BASE_DELAY = 0.1
export function FeaturesPreview() {
const containerRef = useRef<HTMLDivElement>(null)
const inView = useInView(containerRef, { once: true, margin: '-80px' })
return (
<div ref={containerRef} className='relative h-[560px] w-full overflow-hidden'>
<div
aria-hidden='true'
className='absolute inset-0'
style={{
backgroundImage: 'radial-gradient(circle, #D4D4D4 0.75px, transparent 0.75px)',
backgroundSize: '12px 12px',
maskImage: 'radial-gradient(ellipse 70% 65% at 48% 50%, black 30%, transparent 80%)',
WebkitMaskImage:
'radial-gradient(ellipse 70% 65% at 48% 50%, black 30%, transparent 80%)',
}}
/>
{SCATTERED_ICONS.map(({ key, icon: Icon, label, top, left, color }, index) => {
const explodeDelay = EXPLODE_BASE_DELAY + index * EXPLODE_STAGGER
return (
<motion.div
key={key}
className='absolute flex items-center justify-center rounded-xl border border-[#E5E5E5] bg-white p-[10px] shadow-[0_2px_4px_0_rgba(0,0,0,0.06)]'
initial={{ top: '50%', left: '50%', opacity: 0, scale: 0, x: '-50%', y: '-50%' }}
animate={inView ? { top, left, opacity: 1, scale: 1, x: '-50%', y: '-50%' } : undefined}
transition={{
type: 'spring',
stiffness: 50,
damping: 12,
delay: explodeDelay,
}}
style={{ color }}
aria-label={label}
>
<Icon className='h-6 w-6' />
</motion.div>
)
})}
<motion.div
className='absolute top-1/2 left-[48%]'
initial={{ opacity: 0, x: '-50%', y: '-50%' }}
animate={inView ? { opacity: 1, x: '-50%', y: '-50%' } : undefined}
transition={{ duration: 0.4, ease: 'easeOut', delay: 0 }}
>
<div className='flex h-[36px] items-center gap-[8px] rounded-[8px] border border-[#E5E5E5] bg-white px-[10px] shadow-[0_2px_6px_0_rgba(0,0,0,0.08)]'>
<div className='flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-[5px] bg-[#1e1e1e]'>
<svg width='11' height='11' viewBox='0 0 10 10' fill='none'>
<path
d='M1 9C1 4.58 4.58 1 9 1'
stroke='white'
strokeWidth='1.8'
strokeLinecap='round'
/>
</svg>
</div>
<span className='whitespace-nowrap font-medium font-season text-[#1C1C1C] text-[13px] tracking-[0.02em]'>
My Workspace
</span>
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0 text-[#999]' />
</div>
</motion.div>
</div>
)
}

View File

@@ -1,8 +1,11 @@
'use client'
import { useState } from 'react'
import { useRef, useState } from 'react'
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
import Image from 'next/image'
import { Badge } from '@/components/emcn'
import Link from 'next/link'
import { Badge, ChevronDown } from '@/components/emcn'
import { FeaturesPreview } from '@/app/(home)/components/features/components/features-preview'
function hexToRgba(hex: string, alpha: number): string {
const r = Number.parseInt(hex.slice(1, 3), 16)
@@ -13,8 +16,12 @@ function hexToRgba(hex: string, alpha: number): string {
const FEATURE_TABS = [
{
label: 'Integrations',
label: 'Mothership',
color: '#FA4EDF',
title: 'Your AI command center',
description:
'Direct your entire AI workforce from one place. Build agents, spin up workflows, query tables, and manage every resource across your workspace — in natural language.',
cta: 'Explore mothership',
segments: [
[0.3, 8],
[0.25, 10],
@@ -29,8 +36,12 @@ const FEATURE_TABS = [
],
},
{
label: 'Copilot',
label: 'Tables',
color: '#2ABBF8',
title: 'A database, built in',
description:
'Filter, sort, and edit data inline, then wire it directly into your workflows. Agents query, insert, and update rows on every run — no external database needed.',
cta: 'Explore tables',
segments: [
[0.25, 12],
[0.4, 10],
@@ -44,59 +55,33 @@ const FEATURE_TABS = [
],
},
{
label: 'Models',
color: '#00F701',
badgeColor: '#22C55E',
segments: [
[0.2, 6],
[0.35, 10],
[0.3, 8],
[0.5, 10],
[0.6, 8],
[0.75, 12],
[0.85, 10],
[1, 8],
[0.9, 12],
[1, 10],
[0.95, 6],
],
},
{
label: 'Deploy',
label: 'Files',
color: '#FFCC02',
badgeColor: '#EAB308',
segments: [
[0.3, 12],
[0.25, 8],
[0.4, 10],
[0.55, 10],
[0.7, 8],
[0.6, 10],
[0.85, 12],
[1, 10],
[0.9, 10],
[1, 10],
],
},
{
label: 'Logs',
color: '#FF6B35',
title: 'Upload, create, and share',
description:
'Create or upload documents, spreadsheets, and media that agents can read, write, and reference across workflows. One shared store your entire team and every agent can pull from.',
cta: 'Explore files',
segments: [
[0.25, 10],
[0.35, 8],
[0.3, 10],
[0.4, 8],
[0.35, 12],
[0.5, 10],
[0.65, 8],
[0.8, 12],
[0.9, 10],
[0.75, 10],
[0.9, 12],
[1, 10],
[0.85, 12],
[0.85, 10],
[1, 10],
],
},
{
label: 'Knowledge Base',
color: '#8B5CF6',
title: 'Your context engine',
description:
'Sync institutional knowledge from 30+ live connectors — Notion, Drive, Slack, Confluence, and more — so every agent draws from the same truth across your entire organization.',
cta: 'Explore knowledge base',
segments: [
[0.3, 10],
[0.25, 8],
@@ -110,8 +95,47 @@ const FEATURE_TABS = [
[1, 10],
],
},
{
label: 'Logs',
color: '#FF6B35',
title: 'Full visibility, every run',
description:
'Trace every execution block by block — inputs, outputs, cost, and duration. Filter by status or workflow, replay snapshots, and export reports to keep your team accountable.',
cta: 'Explore logs',
segments: [
[0.25, 10],
[0.35, 8],
[0.3, 10],
[0.5, 10],
[0.65, 8],
[0.8, 12],
[0.9, 10],
[1, 10],
[0.85, 12],
[1, 10],
],
},
]
const HEADING_TEXT = 'Everything you need to build, deploy, and manage AI agents. '
const HEADING_LETTERS = HEADING_TEXT.split('')
const LETTER_REVEAL_SPAN = 0.85
const LETTER_FADE_IN = 0.04
interface ScrollLetterProps {
scrollYProgress: MotionValue<number>
charIndex: number
children: string
}
function ScrollLetter({ scrollYProgress, charIndex, children }: ScrollLetterProps) {
const threshold = (charIndex / HEADING_LETTERS.length) * LETTER_REVEAL_SPAN
const opacity = useTransform(scrollYProgress, [threshold, threshold + LETTER_FADE_IN], [0.4, 1])
return <motion.span style={{ opacity }}>{children}</motion.span>
}
function DotGrid({
cols,
rows,
@@ -126,7 +150,7 @@ function DotGrid({
return (
<div
aria-hidden='true'
className={`shrink-0 bg-[#FDFDFD] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
className={`shrink-0 bg-[#F6F6F6] p-[6px] ${borderLeft ? 'border-[#E9E9E9] border-l' : ''}`}
style={{
width: width ? `${width}px` : undefined,
display: 'grid',
@@ -136,20 +160,26 @@ function DotGrid({
}}
>
{Array.from({ length: cols * rows }, (_, i) => (
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#DEDEDE]' />
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#DEDEDE]' />
))}
</div>
)
}
export default function Features() {
const sectionRef = useRef<HTMLDivElement>(null)
const [activeTab, setActiveTab] = useState(0)
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ['start 0.9', 'start 0.2'],
})
return (
<section
id='features'
aria-labelledby='features-heading'
className='relative overflow-hidden bg-[#F6F6F6] pb-[144px]'
className='relative overflow-hidden bg-[#F6F6F6]'
>
<div aria-hidden='true' className='absolute top-0 left-0 w-full'>
<Image
@@ -163,7 +193,7 @@ export default function Features() {
</div>
<div className='relative z-10 pt-[100px]'>
<div className='flex flex-col items-start gap-[20px] px-[80px]'>
<div ref={sectionRef} className='flex flex-col items-start gap-[20px] px-[80px]'>
<Badge
variant='blue'
size='md'
@@ -177,51 +207,110 @@ export default function Features() {
),
}}
>
Features
Workspace
</Badge>
<h2
id='features-heading'
className='font-[430] font-season text-[#1C1C1C] text-[40px] leading-[100%] tracking-[-0.02em]'
className='max-w-[900px] font-[430] font-season text-[#1C1C1C] text-[40px] leading-[110%] tracking-[-0.02em]'
>
Power your AI workforce
{HEADING_LETTERS.map((char, i) => (
<ScrollLetter key={i} scrollYProgress={scrollYProgress} charIndex={i}>
{char}
</ScrollLetter>
))}
<span className='text-[#1C1C1C]/40'>
Design powerful workflows, connect your data, and monitor every run all in one
platform.
</span>
</h2>
</div>
<div className='mt-[73px] flex h-[68px] overflow-hidden border border-[#E9E9E9]'>
<DotGrid cols={10} rows={8} width={80} />
<div className='relative mt-[73px] pb-[80px]'>
<div
aria-hidden='true'
className='absolute top-0 bottom-0 left-[80px] z-20 w-px bg-[#E9E9E9]'
/>
<div
aria-hidden='true'
className='absolute top-0 right-[80px] bottom-0 z-20 w-px bg-[#E9E9E9]'
/>
<div role='tablist' aria-label='Feature categories' className='flex flex-1'>
{FEATURE_TABS.map((tab, index) => (
<button
key={tab.label}
type='button'
role='tab'
aria-selected={index === activeTab}
onClick={() => setActiveTab(index)}
className='relative flex h-full flex-1 items-center justify-center border-[#E9E9E9] border-l font-medium font-season text-[#212121] text-[14px] uppercase'
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
>
{tab.label}
{index === activeTab && (
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
{tab.segments.map(([opacity, width], i) => (
<div
key={i}
className='h-full shrink-0'
style={{
width: `${width}%`,
backgroundColor: tab.color,
opacity,
}}
/>
))}
</div>
)}
</button>
))}
<div className='flex h-[68px] overflow-hidden border border-[#E9E9E9]'>
<DotGrid cols={10} rows={8} width={80} />
<div role='tablist' aria-label='Feature categories' className='flex flex-1'>
{FEATURE_TABS.map((tab, index) => (
<button
key={tab.label}
type='button'
role='tab'
aria-selected={index === activeTab}
onClick={() => setActiveTab(index)}
className='relative flex h-full flex-1 items-center justify-center border-[#E9E9E9] border-l font-medium font-season text-[#212121] text-[14px] uppercase'
style={{ backgroundColor: index === activeTab ? '#FDFDFD' : '#F6F6F6' }}
>
{tab.label}
{index === activeTab && (
<div className='absolute right-0 bottom-0 left-0 flex h-[6px]'>
{tab.segments.map(([opacity, width], i) => (
<div
key={i}
className='h-full shrink-0'
style={{
width: `${width}%`,
backgroundColor: tab.color,
opacity,
}}
/>
))}
</div>
)}
</button>
))}
</div>
<DotGrid cols={10} rows={8} width={80} borderLeft />
</div>
<DotGrid cols={10} rows={8} width={80} borderLeft />
<div className='mt-[60px] grid grid-cols-[1fr_2.8fr] gap-[60px] px-[120px]'>
<div className='flex h-[560px] flex-col items-start justify-between pt-[20px]'>
<div className='flex flex-col items-start gap-[16px]'>
<h3 className='font-[430] font-season text-[#1C1C1C] text-[28px] leading-[120%] tracking-[-0.02em]'>
{FEATURE_TABS[activeTab].title}
</h3>
<p className='font-[430] font-season text-[#1C1C1C]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
{FEATURE_TABS[activeTab].description}
</p>
</div>
<Link
href='/signup'
className='group/cta inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-[10px] font-[430] font-season text-[14px] text-white transition-colors hover:border-[#2A2A2A] hover:bg-[#2A2A2A]'
>
{FEATURE_TABS[activeTab].cta}
<span className='relative h-[10px] w-[10px] shrink-0'>
<ChevronDown className='-rotate-90 absolute inset-0 h-[10px] w-[10px] transition-opacity duration-150 group-hover/cta:opacity-0' />
<svg
className='absolute inset-0 h-[10px] w-[10px] opacity-0 transition-opacity duration-150 group-hover/cta:opacity-100'
viewBox='0 0 10 10'
fill='none'
>
<path
d='M1 5H8M5.5 2L8.5 5L5.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
/>
</svg>
</span>
</Link>
</div>
<FeaturesPreview />
</div>
<div aria-hidden='true' className='mt-[60px] h-px bg-[#E9E9E9]' />
</div>
</div>
</section>

View File

@@ -1,18 +1,189 @@
/**
* Landing page footer — navigation, legal links, and entity reinforcement.
*
* SEO:
* - `<footer role="contentinfo">` with `<nav aria-label="Footer navigation">`.
* - Link groups under semantic headings (`<h3>`). All links are `<Link>` or `<a>` with `href`.
* - External links include `rel="noopener noreferrer"`.
* - Legal links (Privacy, Terms) must be crawlable (trust signals).
*
* GEO:
* - Include "Sim — Build AI agents and run your agentic workforce" as visible text (entity reinforcement).
* - Social links (X, GitHub, LinkedIn, Discord) must match `sameAs` in structured-data.tsx.
* - Link to all major pages: Docs, Pricing, Enterprise, Careers, Changelog (internal link graph).
* - Display compliance badges (SOC2, HIPAA) and status page link as visible trust signals.
*/
export default function Footer() {
return null
import Image from 'next/image'
import Link from 'next/link'
import { FOOTER_BLOCKS, FOOTER_TOOLS } from '@/app/(landing)/components/footer/consts'
const LINK_CLASS = 'text-[14px] text-[#999] transition-colors hover:text-[#ECECEC]'
interface FooterLink {
label: string
href: string
external?: boolean
}
const FOOTER_LINKS: FooterLink[] = [
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
{ label: 'Pricing', href: '#pricing' },
{ label: 'Enterprise', href: 'https://form.typeform.com/to/jqCO12pF', external: true },
{ label: 'Sim Studio', href: '/studio' },
{ label: 'Changelog', href: '/changelog' },
{ label: 'Status', href: 'https://status.sim.ai', external: true },
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
{ label: 'SOC2', href: 'https://trust.delve.co/sim-studio', external: true },
{ label: 'Privacy Policy', href: '/privacy', external: true },
{ label: 'Terms of Service', href: '/terms', external: true },
]
export default function Footer() {
return (
<footer
role='contentinfo'
className='relative w-full overflow-hidden bg-[#1C1C1C] font-[430] font-season text-[14px]'
>
<div className='px-4 pt-[80px] pb-[40px] sm:px-8 sm:pb-[340px] md:px-[80px]'>
<nav aria-label='Footer navigation' className='flex justify-between'>
{/* Brand column */}
<div className='flex flex-col gap-[24px]'>
<Link href='/' aria-label='Sim home'>
<Image
src='/logo/sim-landing.svg'
alt='Sim'
width={71}
height={22}
className='h-[22px] w-auto'
/>
</Link>
</div>
{/* Community column */}
<div>
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>Community</h3>
<div className='flex flex-col gap-[12px]'>
<a
href='https://discord.gg/Hr4UWYEcTT'
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
Discord
</a>
<a
href='https://x.com/simdotai'
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
X (Twitter)
</a>
<a
href='https://www.linkedin.com/company/simstudioai/'
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
LinkedIn
</a>
<a
href='https://github.com/simstudioai/sim'
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
GitHub
</a>
</div>
</div>
{/* Links column */}
<div>
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>More Sim</h3>
<div className='flex flex-col gap-[12px]'>
{FOOTER_LINKS.map(({ label, href, external }) =>
external ? (
<a
key={label}
href={href}
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
{label}
</a>
) : (
<Link key={label} href={href} className={LINK_CLASS}>
{label}
</Link>
)
)}
</div>
</div>
{/* Blocks column */}
<div className='hidden sm:block'>
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>Blocks</h3>
<div className='flex flex-col gap-[12px]'>
{FOOTER_BLOCKS.map((block) => (
<a
key={block}
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replaceAll(' ', '-')}`}
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
{block}
</a>
))}
</div>
</div>
{/* Tools columns */}
<div className='hidden sm:block'>
<h3 className='mb-[16px] font-medium text-[#ECECEC] text-[14px]'>Tools</h3>
<div className='flex gap-[80px]'>
{[0, 1, 2, 3].map((quarter) => {
const start = Math.ceil((FOOTER_TOOLS.length * quarter) / 4)
const end =
quarter === 3
? FOOTER_TOOLS.length
: Math.ceil((FOOTER_TOOLS.length * (quarter + 1)) / 4)
return (
<div key={quarter} className='flex flex-col gap-[12px]'>
{FOOTER_TOOLS.slice(start, end).map((tool) => (
<a
key={tool}
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
target='_blank'
rel='noopener noreferrer'
className={`whitespace-nowrap ${LINK_CLASS}`}
>
{tool}
</a>
))}
</div>
)
})}
</div>
</div>
</nav>
</div>
{/* Large SIM wordmark — half cut off */}
<div className='-translate-x-1/2 pointer-events-none absolute bottom-[-240px] left-1/2 hidden sm:block'>
<svg
xmlns='http://www.w3.org/2000/svg'
width='1128'
height='550'
viewBox='0 0 1128 550'
fill='none'
>
<path
d='M3 420.942H77.9115C77.9115 441.473 85.4027 457.843 100.385 470.051C115.367 481.704 135.621 487.53 161.147 487.53C188.892 487.53 210.255 482.258 225.238 471.715C240.22 460.617 247.711 445.913 247.711 427.601C247.711 414.283 243.549 403.185 235.226 394.307C227.457 385.428 213.03 378.215 191.943 372.666L120.361 356.019C84.2929 347.14 57.3802 333.545 39.6234 315.234C22.4215 296.922 13.8206 272.784 13.8206 242.819C13.8206 217.849 20.2019 196.208 32.9646 177.896C46.2822 159.584 64.3165 145.434 87.0674 135.446C110.373 125.458 137.008 120.464 166.973 120.464C196.938 120.464 222.74 125.735 244.382 136.278C266.578 146.821 283.779 161.526 295.987 180.393C308.75 199.259 315.409 221.733 315.964 247.813H241.052C240.497 226.727 233.561 210.357 220.243 198.705C206.926 187.052 188.337 181.225 164.476 181.225C140.06 181.225 121.194 186.497 107.876 197.04C94.5585 207.583 87.8997 222.01 87.8997 240.322C87.8997 267.512 107.876 286.101 147.829 296.09L219.411 313.569C253.815 321.337 279.618 334.1 296.82 351.857C314.022 369.059 322.622 392.642 322.622 422.607C322.622 448.132 315.686 470.606 301.814 490.027C287.941 508.894 268.797 523.599 244.382 534.142C220.521 544.13 192.221 549.124 159.482 549.124C111.76 549.124 73.7498 537.471 45.4499 514.165C17.15 490.86 3 459.785 3 420.942Z'
fill='#2A2A2A'
/>
<path
d='M377.713 539.136V132.117C408.911 143.439 422.667 143.439 455.954 132.117V539.136H377.713ZM416.001 105.211C402.129 105.211 389.921 100.217 379.378 90.2291C369.39 79.686 364.395 67.4782 364.395 53.6057C364.395 39.1783 369.39 26.9705 379.378 16.9823C389.921 6.9941 402.129 2 416.001 2C430.428 2 442.636 6.9941 452.625 16.9823C462.613 26.9705 467.607 39.1783 467.607 53.6057C467.607 67.4782 462.613 79.686 452.625 90.2291C442.636 100.217 430.428 105.211 416.001 105.211Z'
fill='#2A2A2A'
/>
<path
d='M593.961 539.136H515.72V132.117H585.637V200.792C593.961 178.041 610.053 158.752 632.249 143.769C655 128.232 682.467 120.464 714.651 120.464C750.72 120.464 780.685 130.174 804.545 149.596C822.01 163.812 835.016 181.446 843.562 202.5C851.434 181.446 864.509 163.812 882.786 149.596C907.757 130.174 938.554 120.464 975.177 120.464C1021.79 120.464 1058.41 134.059 1085.05 161.249C1111.68 188.439 1125 225.617 1125 272.784V539.136H1048.42V291.928C1048.42 259.744 1040.1 235.051 1023.45 217.849C1007.36 200.092 985.443 191.213 957.698 191.213C938.276 191.213 921.074 195.653 906.092 204.531C891.665 212.855 880.289 225.062 871.966 241.154C863.642 257.247 859.48 276.113 859.48 297.754V539.136H782.072V291.095C782.072 258.911 774.026 234.496 757.934 217.849C741.841 200.647 719.923 192.046 692.178 192.046C672.756 192.046 655.555 196.485 640.572 205.363C626.145 213.687 614.769 225.895 606.446 241.987C598.122 257.524 593.961 276.113 593.961 297.754V539.136Z'
fill='#2A2A2A'
/>
<path
d='M166.973 121.105C196.396 121.105 221.761 126.201 243.088 136.367L244.101 136.855L244.106 136.858C265.86 147.191 282.776 161.528 294.876 179.865L295.448 180.741L295.455 180.753C308.032 199.345 314.656 221.475 315.306 247.171H241.675C240.996 226.243 234.012 209.899 220.666 198.222C207.196 186.435 188.437 180.583 164.476 180.583C139.977 180.583 120.949 185.871 107.478 196.536C93.9928 207.212 87.2578 221.832 87.2578 240.322C87.2579 254.096 92.3262 265.711 102.444 275.127C112.542 284.524 127.641 291.704 147.673 296.712L147.677 296.713L219.259 314.192L219.27 314.195C253.065 321.827 278.469 334.271 295.552 351.48L296.358 352.304L296.365 352.311C313.42 369.365 321.98 392.77 321.98 422.606C321.98 448.005 315.082 470.343 301.297 489.646C287.502 508.408 268.456 523.046 244.134 533.55C220.369 543.498 192.157 548.482 159.481 548.482C111.864 548.482 74.0124 536.855 45.8584 513.67C17.8723 490.623 3.80059 459.948 3.64551 421.584H77.2734C77.4285 441.995 84.9939 458.338 99.9795 470.549L99.9854 470.553L99.9912 470.558C115.12 482.324 135.527 488.172 161.146 488.172C188.96 488.172 210.474 482.889 225.607 472.24L225.613 472.236L225.619 472.231C240.761 461.015 248.353 446.12 248.353 427.601C248.352 414.145 244.145 402.89 235.709 393.884C227.81 384.857 213.226 377.603 192.106 372.045L192.098 372.043L192.089 372.04L120.507 355.394C84.5136 346.533 57.7326 332.983 40.0908 314.794H40.0918C23.0227 296.624 14.4629 272.654 14.4629 242.819C14.4629 217.969 20.8095 196.463 33.4834 178.273C46.7277 160.063 64.6681 145.981 87.3252 136.034L87.3242 136.033C110.536 126.086 137.081 121.106 166.973 121.105ZM975.177 121.105C1021.66 121.105 1058.1 134.658 1084.59 161.698C1111.08 188.741 1124.36 225.743 1124.36 272.784V538.494H1049.07V291.928C1049.07 259.636 1040.71 234.76 1023.92 217.402H1023.91C1007.68 199.5 985.584 190.571 957.697 190.571C938.177 190.571 920.862 195.034 905.771 203.975C891.228 212.365 879.77 224.668 871.396 240.859C863.017 257.059 858.838 276.03 858.838 297.754V538.494H782.714V291.096C782.714 258.811 774.641 234.209 758.395 217.402C742.16 200.053 720.062 191.404 692.178 191.404C673.265 191.404 656.422 195.592 641.666 203.985L640.251 204.808C625.711 213.196 614.254 225.497 605.88 241.684C597.496 257.333 593.318 276.031 593.318 297.754V538.494H516.361V132.759H584.995V200.792L586.24 201.013C594.51 178.408 610.505 159.221 632.607 144.302L632.61 144.3C655.238 128.847 682.574 121.105 714.651 121.105C750.599 121.105 780.413 130.781 804.14 150.094C821.52 164.241 834.461 181.787 842.967 202.741L843.587 204.268L844.163 202.725C851.992 181.786 864.994 164.248 883.181 150.103C908.021 130.782 938.673 121.106 975.177 121.105ZM455.312 538.494H378.354V133.027C393.534 138.491 404.652 141.251 416.05 141.251C427.46 141.251 439.095 138.485 455.312 133.009V538.494ZM416.001 2.6416C430.262 2.6416 442.306 7.57157 452.171 17.4365C462.036 27.3014 466.965 39.3445 466.965 53.6055C466.965 67.3043 462.04 79.3548 452.16 89.7842C442.297 99.6427 430.258 104.569 416.001 104.569C402.303 104.569 390.254 99.6452 379.825 89.7676C369.957 79.3421 365.037 67.2967 365.037 53.6055C365.037 39.3444 369.966 27.3005 379.831 17.4355C390.258 7.56247 402.307 2.64163 416.001 2.6416Z'
stroke='#3D3D3D'
strokeWidth='1.28396'
/>
</svg>
</div>
</footer>
)
}

View File

@@ -34,7 +34,7 @@ export default function Hero() {
<section
id='hero'
aria-labelledby='hero-heading'
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[71px]'
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[100px] pb-[12px]'
>
<p className='sr-only'>
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect
@@ -53,7 +53,7 @@ export default function Hero() {
<div
aria-hidden='true'
className='pointer-events-none absolute top-[-2.8vw] right-[0vw] z-0 aspect-[471/470] w-[32.7vw]'
className='pointer-events-none absolute top-[-2.8vw] right-[-4vw] z-0 aspect-[471/470] w-[32.7vw]'
>
<Image src='/landing/card-right.svg' alt='' fill className='object-contain' />
</div>
@@ -61,25 +61,25 @@ export default function Hero() {
<div className='relative z-10 flex flex-col items-center gap-[12px]'>
<h1
id='hero-heading'
className='font-[430] font-season text-[64px] text-white leading-[100%] tracking-[-0.02em]'
className='font-[430] font-season text-[72px] text-white leading-[100%] tracking-[-0.02em]'
>
Build Agents
Build AI Agents
</h1>
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[16px] leading-[125%] tracking-[0.02em]'>
Build and deploy agentic workflows
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[18px] leading-[125%] tracking-[0.02em]'>
Sim is the AI Workspace for Agent Builders.
</p>
<div className='mt-[12px] flex items-center gap-[8px]'>
<Link
href='/login'
href='/enterprise'
className={`${CTA_BASE} border-[#3d3d3d] text-[#ECECEC] transition-colors hover:bg-[#2A2A2A]`}
aria-label='Log in'
aria-label='Get a demo'
>
Log in
Get a demo
</Link>
<Link
href='/signup'
className={`${CTA_BASE} gap-[8px] border-[#33C482] bg-[#33C482] text-black transition-[filter] hover:brightness-110`}
className={`${CTA_BASE} gap-[8px] border-[#FFFFFF] bg-[#FFFFFF] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]`}
aria-label='Get started with Sim'
>
Get started
@@ -101,7 +101,7 @@ export default function Hero() {
<BlocksTopLeftAnimated animState={blockStates.topLeft} />
</div>
<div className='relative z-10 mx-auto mt-[2.4vw] w-[78.9vw] px-[1.4vw]'>
<div className='relative z-10 mx-auto mt-[3.2vw] w-[78.9vw] px-[1.4vw]'>
<div
aria-hidden='true'
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'

View File

@@ -0,0 +1,98 @@
'use client'
import { memo, useCallback, useRef, useState } from 'react'
import { ArrowUp } from 'lucide-react'
import { useLandingSubmit } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
import { useAnimatedPlaceholder } from '@/app/workspace/[workspaceId]/home/hooks/use-animated-placeholder'
const C = {
SURFACE: '#292929',
BORDER: '#3d3d3d',
TEXT_PRIMARY: '#e6e6e6',
} as const
/**
* Landing preview replica of the workspace Home initial view.
* Shows a greeting heading and a minimal chat input (no + or mic).
* On submit, stores the prompt and redirects to /signup.
*/
export const LandingPreviewHome = memo(function LandingPreviewHome() {
const landingSubmit = useLandingSubmit()
const [inputValue, setInputValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const animatedPlaceholder = useAnimatedPlaceholder()
const isEmpty = inputValue.trim().length === 0
const handleSubmit = useCallback(() => {
if (isEmpty) return
landingSubmit(inputValue)
}, [isEmpty, inputValue, landingSubmit])
const MAX_HEIGHT = 200
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
},
[handleSubmit]
)
const handleInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = `${Math.min(target.scrollHeight, MAX_HEIGHT)}px`
}, [])
return (
<div className='flex min-w-0 flex-1 flex-col items-center justify-center px-[24px] pb-[2vh]'>
<h1
className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] tracking-[-0.02em]'
style={{ color: C.TEXT_PRIMARY }}
>
What should we get done?
</h1>
<div className='w-full max-w-[32rem]'>
<div
className='cursor-text rounded-[20px] border px-[10px] py-[8px]'
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE }}
onClick={() => textareaRef.current?.focus()}
>
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder={animatedPlaceholder}
rows={1}
className='m-0 box-border min-h-[24px] w-full resize-none overflow-y-auto border-0 bg-transparent px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em] outline-none placeholder:font-[380] placeholder:text-[#787878] focus-visible:ring-0'
style={{
color: C.TEXT_PRIMARY,
caretColor: C.TEXT_PRIMARY,
maxHeight: `${MAX_HEIGHT}px`,
}}
/>
<div className='flex items-center justify-end'>
<button
type='button'
onClick={handleSubmit}
disabled={isEmpty}
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
style={{
background: isEmpty ? '#808080' : '#e0e0e0',
cursor: isEmpty ? 'not-allowed' : 'pointer',
}}
>
<ArrowUp size={16} strokeWidth={2.25} color='#1b1b1b' />
</button>
</div>
</div>
</div>
</div>
)
})

View File

@@ -8,6 +8,23 @@ import { createPortal } from 'react-dom'
import { BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@/components/emcn'
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
/**
* Stores the prompt in browser storage and redirects to /signup.
* Shared by both the copilot panel and the landing home view.
*/
export function useLandingSubmit() {
const router = useRouter()
return useCallback(
(text: string) => {
const trimmed = text.trim()
if (!trimmed) return
LandingPromptStorage.store(trimmed)
router.push('/signup')
},
[router]
)
}
/**
* Lightweight static panel replicating the real workspace panel styling.
* The copilot tab is active with a functional user input.
@@ -18,7 +35,7 @@ import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
* inside Content > Copilot > header-bar(mx-[-1px]) > UserInput(p-8)
*/
export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
const router = useRouter()
const landingSubmit = useLandingSubmit()
const [inputValue, setInputValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null)
@@ -27,9 +44,8 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
const handleSubmit = useCallback(() => {
if (isEmpty) return
LandingPromptStorage.store(inputValue)
router.push('/signup')
}, [isEmpty, inputValue, router])
landingSubmit(inputValue)
}, [isEmpty, inputValue, landingSubmit])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -60,10 +76,10 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel() {
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
onMouseLeave={() => setCursorPos(null)}
>
<div className='flex h-[30px] items-center rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
<div className='flex h-[30px] items-center rounded-[5px] bg-[#33C482] px-[10px] transition-colors hover:bg-[#2DAC72]'>
<span className='font-medium text-[#1b1b1b] text-[12px]'>Deploy</span>
</div>
<div className='flex h-[30px] items-center gap-[8px] rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
<div className='flex h-[30px] items-center gap-[8px] rounded-[5px] bg-[#33C482] px-[10px] transition-colors hover:bg-[#2DAC72]'>
<Play className='h-[11.5px] w-[11.5px] text-[#1b1b1b]' />
<span className='font-medium text-[#1b1b1b] text-[12px]'>Run</span>
</div>

View File

@@ -1,141 +1,204 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Database, Layout, Search, Settings } from 'lucide-react'
import { ChevronDown, Library } from '@/components/emcn'
import { ChevronDown, Home, Library } from '@/components/emcn'
import {
Calendar,
Database,
File,
HelpCircle,
Search,
Settings,
Table,
} from '@/components/emcn/icons'
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
/**
* Props for the LandingPreviewSidebar component
*/
interface LandingPreviewSidebarProps {
workflows: PreviewWorkflow[]
activeWorkflowId: string
activeView: 'home' | 'workflow'
onSelectWorkflow: (id: string) => void
onSelectHome: () => void
}
/**
* Static footer navigation items matching the real sidebar
* Hardcoded dark-theme equivalents of the real sidebar CSS variables.
* The preview lives inside a `dark` wrapper but CSS variable cascade
* isn't guaranteed, so we pin the hex values directly.
*/
const FOOTER_NAV_ITEMS = [
{ id: 'logs', label: 'Logs', icon: Library },
{ id: 'templates', label: 'Templates', icon: Layout },
const C = {
SURFACE_1: '#1e1e1e',
SURFACE_2: '#252525',
SURFACE_ACTIVE: '#363636',
BORDER: '#2c2c2c',
TEXT_PRIMARY: '#e6e6e6',
TEXT_BODY: '#cdcdcd',
TEXT_ICON: '#939393',
BRAND: '#33C482',
} as const
const WORKSPACE_NAV = [
{ id: 'tables', label: 'Tables', icon: Table },
{ id: 'files', label: 'Files', icon: File },
{ id: 'knowledge-base', label: 'Knowledge Base', icon: Database },
{ id: 'scheduled-tasks', label: 'Scheduled Tasks', icon: Calendar },
{ id: 'logs', label: 'Logs', icon: Library },
] as const
const FOOTER_NAV = [
{ id: 'help', label: 'Help', icon: HelpCircle },
{ id: 'settings', label: 'Settings', icon: Settings },
] as const
function StaticNavItem({
icon: Icon,
label,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>
label: string
}) {
return (
<div className='pointer-events-none mx-[2px] flex h-[28px] items-center gap-[8px] rounded-[8px] px-[8px]'>
<Icon className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
{label}
</span>
</div>
)
}
/**
* Lightweight static sidebar replicating the real workspace sidebar styling.
* Lightweight sidebar replicating the real workspace sidebar layout and sizing.
* Starts from the workspace header (no logo/collapse row).
* Only workflow items are interactive — everything else is pointer-events-none.
*
* Colors sourced from the dark theme CSS variables:
* --surface-1: #1e1e1e, --surface-5: #363636, --border: #2c2c2c, --border-1: #3d3d3d
* --text-primary: #e6e6e6, --text-tertiary: #b3b3b3, --text-muted: #787878
*/
export function LandingPreviewSidebar({
workflows,
activeWorkflowId,
activeView,
onSelectWorkflow,
onSelectHome,
}: LandingPreviewSidebarProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const handleToggle = useCallback(() => {
setIsDropdownOpen((prev) => !prev)
}, [])
useEffect(() => {
if (!isDropdownOpen) return
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsDropdownOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isDropdownOpen])
const isHomeActive = activeView === 'home'
return (
<div className='flex h-full w-[220px] flex-shrink-0 flex-col border-[#2c2c2c] border-r bg-[#1e1e1e]'>
{/* Header */}
<div className='relative flex-shrink-0 px-[14px] pt-[12px]' ref={dropdownRef}>
<div className='flex items-center justify-between'>
<button
type='button'
onClick={handleToggle}
className='group -mx-[6px] flex cursor-pointer items-center gap-[8px] rounded-[6px] bg-transparent px-[6px] py-[4px] transition-colors hover:bg-[#363636]'
<div
className='flex h-full w-[248px] flex-shrink-0 flex-col pt-[12px] tracking-[0.02em]'
style={{ backgroundColor: C.SURFACE_1 }}
>
{/* Workspace Header */}
<div className='flex-shrink-0 px-[10px]'>
<div
className='pointer-events-none flex h-[32px] w-full items-center gap-[8px] rounded-[8px] border pr-[8px] pl-[5px]'
style={{ borderColor: C.BORDER, backgroundColor: C.SURFACE_2 }}
>
<div className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-white'>
<svg width='10' height='10' viewBox='0 0 10 10' fill='none'>
<path
d='M1 9C1 4.58 4.58 1 9 1'
stroke='#1e1e1e'
strokeWidth='1.8'
strokeLinecap='round'
/>
</svg>
</div>
<span
className='min-w-0 flex-1 truncate text-left font-medium text-[13px]'
style={{ color: C.TEXT_PRIMARY }}
>
<span className='truncate font-base text-[#e6e6e6] text-[14px]'>My Workspace</span>
<ChevronDown
className={`h-[8px] w-[10px] flex-shrink-0 text-[#787878] transition-all duration-100 group-hover:text-[#cccccc] ${isDropdownOpen ? 'rotate-180' : ''}`}
/>
</button>
<div className='pointer-events-none flex flex-shrink-0 items-center'>
<Search className='h-[14px] w-[14px] text-[#787878]' />
Superark
</span>
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
</div>
</div>
{/* Top Navigation: Home (interactive), Search (static) */}
<div className='mt-[10px] flex flex-shrink-0 flex-col gap-[2px] px-[8px]'>
<button
type='button'
onClick={onSelectHome}
className='mx-[2px] flex h-[28px] items-center gap-[8px] rounded-[8px] px-[8px] transition-colors'
style={{ backgroundColor: isHomeActive ? C.SURFACE_ACTIVE : 'transparent' }}
onMouseEnter={(e) => {
if (!isHomeActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
}}
onMouseLeave={(e) => {
if (!isHomeActive) e.currentTarget.style.backgroundColor = 'transparent'
}}
>
<Home className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
Home
</span>
</button>
<StaticNavItem icon={Search} label='Search' />
</div>
{/* Workspace */}
<div className='mt-[14px] flex flex-shrink-0 flex-col'>
<div className='px-[16px] pb-[6px]'>
<div className='font-base text-[12px]' style={{ color: C.TEXT_ICON }}>
Workspace
</div>
</div>
<div className='flex flex-col gap-[2px] px-[8px]'>
{WORKSPACE_NAV.map((item) => (
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
))}
</div>
</div>
{/* Workspace switcher dropdown */}
{isDropdownOpen && (
<div className='absolute top-[42px] left-[8px] z-50 min-w-[160px] max-w-[160px] rounded-[6px] bg-[#242424] px-[6px] py-[6px] shadow-lg'>
<div
className='flex h-[26px] cursor-pointer items-center gap-[8px] rounded-[6px] bg-[#3d3d3d] px-[6px] font-base text-[#e6e6e6] text-[13px]'
role='menuitem'
onClick={() => setIsDropdownOpen(false)}
>
<span className='min-w-0 flex-1 truncate'>My Workspace</span>
{/* Scrollable Tasks + Workflows */}
<div className='flex flex-1 flex-col overflow-y-auto overflow-x-hidden pt-[14px]'>
{/* Workflows */}
<div className='flex flex-col'>
<div className='px-[16px]'>
<div className='font-base text-[12px]' style={{ color: C.TEXT_ICON }}>
Workflows
</div>
</div>
)}
</div>
{/* Workflow items */}
<div className='mt-[8px] space-y-[2px] overflow-x-hidden px-[8px]'>
{workflows.map((workflow) => {
const isActive = workflow.id === activeWorkflowId
return (
<button
key={workflow.id}
type='button'
onClick={() => onSelectWorkflow(workflow.id)}
className={`group flex h-[26px] w-full items-center gap-[8px] rounded-[8px] px-[6px] text-[14px] transition-colors ${
isActive ? 'bg-[#363636]' : 'bg-transparent hover:bg-[#363636]'
}`}
>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px]'
style={{ backgroundColor: workflow.color }}
/>
<div className='min-w-0 flex-1'>
<div
className={`min-w-0 truncate text-left font-medium ${
isActive ? 'text-[#e6e6e6]' : 'text-[#b3b3b3] group-hover:text-[#e6e6e6]'
}`}
<div className='mt-[6px] flex flex-col gap-[2px] px-[8px]'>
{workflows.map((workflow) => {
const isActive = activeView === 'workflow' && workflow.id === activeWorkflowId
return (
<button
key={workflow.id}
type='button'
onClick={() => onSelectWorkflow(workflow.id)}
className='group mx-[2px] flex h-[28px] w-full items-center gap-[8px] rounded-[8px] px-[8px] transition-colors'
style={{ backgroundColor: isActive ? C.SURFACE_ACTIVE : 'transparent' }}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'
}}
>
{workflow.name}
</div>
</div>
</button>
)
})}
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px] border-[2.5px]'
style={{
backgroundColor: workflow.color,
borderColor: `${workflow.color}60`,
backgroundClip: 'padding-box',
}}
/>
<div
className='min-w-0 flex-1 truncate text-left text-[13px]'
style={{ color: C.TEXT_BODY, fontWeight: 450 }}
>
{workflow.name}
</div>
</button>
)
})}
</div>
</div>
</div>
{/* Footer navigation — static */}
<div className='pointer-events-none mt-auto flex flex-shrink-0 flex-col gap-[2px] border-[#2c2c2c] border-t px-[7.75px] pt-[8px] pb-[8px]'>
{FOOTER_NAV_ITEMS.map((item) => {
const Icon = item.icon
return (
<div
key={item.id}
className='flex h-[26px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px]'
>
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[#b3b3b3]' />
<span className='truncate font-medium text-[#b3b3b3] text-[13px]'>{item.label}</span>
</div>
)
})}
{/* Footer */}
<div className='flex flex-shrink-0 flex-col gap-[2px] px-[8px] pt-[9px] pb-[8px]'>
{FOOTER_NAV.map((item) => (
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
))}
</div>
</div>
)

View File

@@ -4,6 +4,7 @@ import { memo } from 'react'
import { motion } from 'framer-motion'
import { Database } from 'lucide-react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { Blimp } from '@/components/emcn'
import {
AgentIcon,
AnthropicIcon,
@@ -63,6 +64,7 @@ const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
reducto: ReductoIcon,
textract: TextractIcon,
linkedin: LinkedInIcon,
mothership: Blimp,
}
/** Model prefix → provider icon for the "Model" row in agent blocks. */

View File

@@ -91,11 +91,11 @@ const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
}
/**
* Content pipeline workflow — Schedule -> Agent (X + YouTube tools)
* Self-healing CRM workflow — Schedule -> Mothership
*/
const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
id: 'wf-content-pipeline',
name: 'Content Pipeline',
const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
id: 'wf-self-healing-crm',
name: 'Self-healing CRM',
color: '#33C482',
blocks: [
{
@@ -111,23 +111,16 @@ const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
hideTargetHandle: true,
},
{
id: 'agent-2',
name: 'Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'grok-4' },
{ title: 'System Prompt', value: 'Repurpose trending...' },
],
tools: [
{ name: 'X', type: 'x', bgColor: '#000000' },
{ name: 'YouTube', type: 'youtube', bgColor: '#FF0000' },
],
id: 'mothership-1',
name: 'Update Agent',
type: 'mothership',
bgColor: '#33C482',
rows: [{ title: 'Prompt', value: 'Audit CRM records, fix...' }],
position: { x: 420, y: 180 },
hideSourceHandle: true,
},
],
edges: [{ id: 'e-3', source: 'schedule-1', target: 'agent-2' }],
edges: [{ id: 'e-3', source: 'schedule-1', target: 'mothership-1' }],
}
/**
@@ -154,7 +147,7 @@ const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
}
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
CONTENT_PIPELINE_WORKFLOW,
SELF_HEALING_CRM_WORKFLOW,
IT_SERVICE_WORKFLOW,
NEW_AGENT_WORKFLOW,
]

View File

@@ -1,7 +1,8 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { motion, type Variants } from 'framer-motion'
import { LandingPreviewHome } from '@/app/(home)/components/landing-preview/components/landing-preview-home/landing-preview-home'
import { LandingPreviewPanel } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
import { LandingPreviewSidebar } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
@@ -56,6 +57,7 @@ const panelVariants: Variants = {
* load — workflow switches render instantly.
*/
export function LandingPreview() {
const [activeView, setActiveView] = useState<'home' | 'workflow'>('workflow')
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
const isInitialMount = useRef(true)
@@ -63,12 +65,23 @@ export function LandingPreview() {
isInitialMount.current = false
}, [])
const handleSelectWorkflow = useCallback((id: string) => {
setActiveWorkflowId(id)
setActiveView('workflow')
}, [])
const handleSelectHome = useCallback(() => {
setActiveView('home')
}, [])
const activeWorkflow =
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
const isWorkflowView = activeView === 'workflow'
return (
<motion.div
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[#1b1b1b] antialiased'
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[#1e1e1e] antialiased'
initial='hidden'
animate='visible'
variants={containerVariants}
@@ -77,15 +90,34 @@ export function LandingPreview() {
<LandingPreviewSidebar
workflows={PREVIEW_WORKFLOWS}
activeWorkflowId={activeWorkflowId}
onSelectWorkflow={setActiveWorkflowId}
activeView={activeView}
onSelectWorkflow={handleSelectWorkflow}
onSelectHome={handleSelectHome}
/>
</motion.div>
<div className='relative flex-1 overflow-hidden'>
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
<div className='flex flex-1 overflow-hidden rounded-[8px] border border-[#2c2c2c] bg-[#1b1b1b]'>
<div
className={
isWorkflowView
? 'relative min-w-0 flex-1 overflow-hidden'
: 'relative flex min-w-0 flex-1 flex-col overflow-hidden'
}
>
{isWorkflowView ? (
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
) : (
<LandingPreviewHome />
)}
</div>
<motion.div
className={isWorkflowView ? 'hidden lg:flex' : 'hidden'}
variants={panelVariants}
>
<LandingPreviewPanel />
</motion.div>
</div>
</div>
<motion.div className='hidden lg:flex' variants={panelVariants}>
<LandingPreviewPanel />
</motion.div>
</motion.div>
)
}

View File

@@ -11,7 +11,7 @@ interface NavLink {
}
const NAV_LINKS: NavLink[] = [
{ label: 'Docs', href: '/docs', icon: 'chevron' },
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Careers', href: '/careers' },
{ label: 'Enterprise', href: '/enterprise' },
@@ -86,7 +86,7 @@ export default function Navbar() {
</Link>
<Link
href='/signup'
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[9px] text-[13.5px] text-black transition-[filter] hover:brightness-110'
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
aria-label='Get started with Sim'
>
Get started

View File

@@ -123,7 +123,7 @@ function PricingCard({ tier }: PricingCardProps) {
) : isPro ? (
<Link
href={tier.cta.href}
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-white transition-[filter] hover:brightness-110'
className='flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[#1D1D1D] bg-[#1D1D1D] px-[10px] font-[430] font-season text-[14px] text-white transition-colors hover:border-[#2A2A2A] hover:bg-[#2A2A2A]'
>
{tier.cta.label}
</Link>
@@ -174,7 +174,7 @@ function PricingCard({ tier }: PricingCardProps) {
export default function Pricing() {
return (
<section id='pricing' aria-labelledby='pricing-heading' className='bg-[#F6F6F6]'>
<div className='px-4 pt-[100px] pb-8 sm:px-8 md:px-[80px]'>
<div className='px-4 pt-[100px] pb-[80px] sm:px-8 md:px-[80px]'>
<div className='flex flex-col items-start gap-3 sm:gap-4 md:gap-[20px]'>
<Badge
variant='blue'

View File

@@ -337,7 +337,7 @@ function DotGrid({ className, cols, rows, gap = 0 }: DotGridProps) {
}}
>
{Array.from({ length: cols * rows }, (_, i) => (
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
<div key={i} className='h-[1.5px] w-[1.5px] rounded-full bg-[#2A2A2A]' />
))}
</div>
)
@@ -462,7 +462,7 @@ export default function Templates() {
Ship your agent in minutes
</h2>
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[16px] leading-[125%] tracking-[0.02em]'>
<p className='font-[430] font-season text-[#F6F6F0]/50 text-[18px] leading-[150%] tracking-[0.02em]'>
Pre-built templates for every use casepick one, swap <br />
models and tools to fit your stack, and deploy.
</p>
@@ -557,7 +557,7 @@ export default function Templates() {
type='button'
onClick={handleUseTemplate}
disabled={isPreparingTemplate}
className='group/cta absolute top-[16px] right-[16px] z-10 inline-flex h-[32px] cursor-pointer items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
className='group/cta absolute top-[16px] right-[16px] z-10 inline-flex h-[32px] cursor-pointer items-center gap-[6px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[10px] font-[430] font-season text-[14px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
{isPreparingTemplate ? 'Preparing...' : 'Use template'}
<span className='relative h-[10px] w-[10px] shrink-0'>

View File

@@ -41,6 +41,19 @@
opacity: 0;
}
@keyframes sidebar-collapse-guard {
from {
pointer-events: none;
}
to {
pointer-events: auto;
}
}
.sidebar-container[data-collapsed] {
animation: sidebar-collapse-guard 250ms step-end;
}
.sidebar-container.is-resizing {
transition: none;
}
@@ -145,7 +158,7 @@
--brand-400: #8e4cfb;
--brand-secondary: #33b4ff;
--brand-tertiary: #22c55e;
--brand-tertiary-2: #32bd7e;
--brand-tertiary-2: #33c482;
--selection: #1a5cf6;
--warning: #ea580c;
@@ -267,7 +280,7 @@
--brand-400: #8e4cfb;
--brand-secondary: #33b4ff;
--brand-tertiary: #22c55e;
--brand-tertiary-2: #32bd7e;
--brand-tertiary-2: #33c482;
--selection: #4b83f7;
--warning: #ff6600;
@@ -790,6 +803,59 @@ input[type="search"]::-ms-clear {
}
}
@keyframes notification-enter {
from {
opacity: 0;
transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97);
}
to {
opacity: 1;
transform: translateX(var(--stack-offset, 0px)) scale(1);
}
}
@keyframes notification-countdown {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: 34.56;
}
}
@keyframes notification-exit {
from {
opacity: 1;
transform: translateX(var(--stack-offset, 0px)) scale(1);
}
to {
opacity: 0;
transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97);
}
}
@keyframes toast-enter {
from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes toast-exit {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
}
/* WandPromptBar status indicator */
@keyframes smoke-pulse {
0%,

View File

@@ -171,7 +171,7 @@ export async function GET(request: NextRequest) {
([category, templates]) => `
<h2 style="margin-top: 24px; margin-bottom: 12px; font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">${category}</h2>
<ul style="list-style: none; padding: 0; margin: 0;">
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #32bd7e; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #33C482; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
</ul>
`
)

View File

@@ -46,9 +46,10 @@ export async function POST(request: NextRequest) {
const formData = await request.formData()
const files = formData.getAll('file') as File[]
const rawFiles = formData.getAll('file')
const files = rawFiles.filter((f): f is File => f instanceof File)
if (!files || files.length === 0) {
if (files.length === 0) {
throw new InvalidRequestError('No files provided')
}
@@ -74,6 +75,9 @@ export async function POST(request: NextRequest) {
for (const file of files) {
const originalName = file.name
if (!originalName) {
throw new InvalidRequestError('File name is missing')
}
if (!validateFileExtension(originalName)) {
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'

View File

@@ -87,32 +87,36 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
const formData = await request.formData()
const file = formData.get('file') as File
const rawFile = formData.get('file')
if (!file) {
if (!rawFile || !(rawFile instanceof File)) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
// Validate file size (100MB limit)
const fileName = rawFile.name
if (!fileName) {
return NextResponse.json({ error: 'File name is missing' }, { status: 400 })
}
const maxSize = 100 * 1024 * 1024
if (file.size > maxSize) {
if (rawFile.size > maxSize) {
return NextResponse.json(
{ error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` },
{ error: `File size exceeds 100MB limit (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)` },
{ status: 400 }
)
}
const buffer = Buffer.from(await file.arrayBuffer())
const buffer = Buffer.from(await rawFile.arrayBuffer())
const userFile = await uploadWorkspaceFile(
workspaceId,
session.user.id,
buffer,
file.name,
file.type || 'application/octet-stream'
fileName,
rawFile.type || 'application/octet-stream'
)
logger.info(`[${requestId}] Uploaded workspace file: ${file.name}`)
logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`)
recordAudit({
workspaceId,
@@ -122,8 +126,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
action: AuditAction.FILE_UPLOADED,
resourceType: AuditResourceType.FILE,
resourceId: userFile.id,
resourceName: file.name,
description: `Uploaded file "${file.name}"`,
resourceName: fileName,
description: `Uploaded file "${fileName}"`,
request,
})

View File

@@ -11,7 +11,7 @@ interface ThankYouScreenProps {
}
/** Default green color matching --brand-tertiary-2 */
const DEFAULT_THANK_YOU_COLOR = '#32bd7e'
const DEFAULT_THANK_YOU_COLOR = '#33C482'
/** Legacy blue default that should be treated as "no custom color" */
const LEGACY_BLUE_DEFAULT = '#3972F6'

View File

@@ -1249,7 +1249,7 @@ export default function ResumeExecutionPage({
{message && <Badge variant='green'>{message}</Badge>}
{/* Action */}
<Button variant='tertiary' onClick={handleResume} disabled={resumeDisabled}>
<Button variant='primary' onClick={handleResume} disabled={resumeDisabled}>
{loadingAction ? 'Resuming...' : 'Resume Execution'}
</Button>
</>

View File

@@ -733,7 +733,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
<>
{!currentUserId ? (
<Button
variant='tertiary'
variant='primary'
onClick={() => {
const callbackUrl =
isWorkspaceContext && workspaceId
@@ -749,7 +749,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
</Button>
) : isWorkspaceContext ? (
<Button
variant='tertiary'
variant='primary'
onClick={handleUseTemplate}
disabled={isUsing}
className='!text-[#FFFFFF] h-[32px] rounded-[6px] px-[12px] text-[14px]'

View File

@@ -1,5 +1,6 @@
export { MessageContent } from './message-content'
export { MothershipView } from './mothership-view'
export { QueuedMessages } from './queued-messages'
export { TemplatePrompts } from './template-prompts'
export { UserInput } from './user-input'
export { UserMessageContent } from './user-message-content'

View File

@@ -91,14 +91,8 @@ export function MothershipView({
previewMode={isActivePreviewable ? previewMode : undefined}
/>
) : (
<div className='flex h-full flex-col items-center justify-center gap-[4px] px-[24px]'>
<h2 className='font-semibold text-[20px] text-[var(--text-primary)]'>
No resources open
</h2>
<p className='text-[12px] text-[var(--text-body)]'>
Click the <span className='font-medium text-[var(--text-primary)]'>+</span> button
above to add a resource to this task
</p>
<div className='flex h-full items-center justify-center text-[14px] text-[var(--text-muted)]'>
Click "+" above to add a resource
</div>
)}
</div>

View File

@@ -0,0 +1 @@
export { QueuedMessages } from './queued-messages'

View File

@@ -0,0 +1,113 @@
'use client'
import { useState } from 'react'
import { ArrowUp, ChevronDown, ChevronRight, Pencil, Trash2 } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import type { QueuedMessage } from '@/app/workspace/[workspaceId]/home/types'
interface QueuedMessagesProps {
messageQueue: QueuedMessage[]
onRemove: (id: string) => void
onSendNow: (id: string) => Promise<void>
onEdit: (id: string) => void
}
export function QueuedMessages({ messageQueue, onRemove, onSendNow, onEdit }: QueuedMessagesProps) {
const [isExpanded, setIsExpanded] = useState(true)
if (messageQueue.length === 0) return null
return (
<div className='-mb-[12px] mx-[14px] overflow-hidden rounded-t-[16px] border border-[var(--border-1)] border-b-0 bg-[var(--surface-2)] pb-[12px] dark:bg-[var(--surface-3)]'>
<button
type='button'
onClick={() => setIsExpanded(!isExpanded)}
className='flex w-full items-center gap-[6px] px-[14px] py-[8px] transition-colors hover:bg-black/[0.03] dark:hover:bg-white/[0.03]'
>
{isExpanded ? (
<ChevronDown className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
) : (
<ChevronRight className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
)}
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
{messageQueue.length} Queued
</span>
</button>
{isExpanded && (
<div>
{messageQueue.map((msg) => (
<div
key={msg.id}
className='flex items-center gap-[8px] px-[14px] py-[6px] transition-colors hover:bg-black/[0.03] dark:hover:bg-white/[0.03]'
>
<div className='flex h-[16px] w-[16px] shrink-0 items-center justify-center'>
<div className='h-[10px] w-[10px] rounded-full border-[1.5px] border-[var(--text-tertiary)]/40' />
</div>
<div className='min-w-0 flex-1'>
<p className='truncate text-[13px] text-[var(--text-primary)]'>{msg.content}</p>
</div>
<div className='flex shrink-0 items-center gap-[2px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
onEdit(msg.id)
}}
className='rounded-[6px] p-[5px] text-[var(--text-tertiary)] transition-colors hover:bg-black/[0.06] hover:text-[var(--text-primary)] dark:hover:bg-white/[0.06]'
>
<Pencil className='h-[13px] w-[13px]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' sideOffset={4}>
Edit queued message
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
void onSendNow(msg.id)
}}
className='rounded-[6px] p-[5px] text-[var(--text-tertiary)] transition-colors hover:bg-black/[0.06] hover:text-[var(--text-primary)] dark:hover:bg-white/[0.06]'
>
<ArrowUp className='h-[13px] w-[13px]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' sideOffset={4}>
Send now
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
onRemove(msg.id)
}}
className='rounded-[6px] p-[5px] text-[var(--text-tertiary)] transition-colors hover:bg-black/[0.06] hover:text-[var(--text-primary)] dark:hover:bg-white/[0.06]'
>
<Trash2 className='h-[13px] w-[13px]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' sideOffset={4}>
Remove from queue
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -64,7 +64,10 @@ import { cn } from '@/lib/core/utils/cn'
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import type {
FileAttachmentForApi,
MothershipResource,
} from '@/app/workspace/[workspaceId]/home/types'
import {
useContextManagement,
useFileAttachments,
@@ -125,9 +128,17 @@ function autoResizeTextarea(e: React.FormEvent<HTMLTextAreaElement>, maxHeight:
function mapResourceToContext(resource: MothershipResource): ChatContext {
switch (resource.type) {
case 'workflow':
return { kind: 'workflow', workflowId: resource.id, label: resource.title }
return {
kind: 'workflow',
workflowId: resource.id,
label: resource.title,
}
case 'knowledgebase':
return { kind: 'knowledge', knowledgeId: resource.id, label: resource.title }
return {
kind: 'knowledge',
knowledgeId: resource.id,
label: resource.title,
}
case 'table':
return { kind: 'table', tableId: resource.id, label: resource.title }
case 'file':
@@ -137,16 +148,12 @@ function mapResourceToContext(resource: MothershipResource): ChatContext {
}
}
export interface FileAttachmentForApi {
id: string
key: string
filename: string
media_type: string
size: number
}
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
interface UserInputProps {
defaultValue?: string
editValue?: string
onEditValueConsumed?: () => void
onSubmit: (
text: string,
fileAttachments?: FileAttachmentForApi[],
@@ -161,6 +168,8 @@ interface UserInputProps {
export function UserInput({
defaultValue = '',
editValue,
onEditValueConsumed,
onSubmit,
isSending,
onStopGeneration,
@@ -176,9 +185,27 @@ export function UserInput({
const [plusMenuActiveIndex, setPlusMenuActiveIndex] = useState(0)
const overlayRef = useRef<HTMLDivElement>(null)
const [prevDefaultValue, setPrevDefaultValue] = useState(defaultValue)
if (defaultValue && defaultValue !== prevDefaultValue) {
setPrevDefaultValue(defaultValue)
setValue(defaultValue)
} else if (!defaultValue && prevDefaultValue) {
setPrevDefaultValue(defaultValue)
}
const [prevEditValue, setPrevEditValue] = useState(editValue)
if (editValue && editValue !== prevEditValue) {
setPrevEditValue(editValue)
setValue(editValue)
} else if (!editValue && prevEditValue) {
setPrevEditValue(editValue)
}
useEffect(() => {
if (defaultValue) setValue(defaultValue)
}, [defaultValue])
if (editValue) {
onEditValueConsumed?.()
}
}, [editValue, onEditValueConsumed])
const animatedPlaceholder = useAnimatedPlaceholder(isInitialView)
const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
@@ -393,9 +420,7 @@ export function UserInput({
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault()
if (!isSending) {
handleSubmit()
}
handleSubmit()
return
}
@@ -461,7 +486,7 @@ export function UserInput({
}
}
},
[handleSubmit, isSending, mentionTokensWithContext, value, textareaRef]
[handleSubmit, mentionTokensWithContext, value, textareaRef]
)
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
@@ -637,7 +662,9 @@ export function UserInput({
<span
key={`mention-${i}-${range.start}-${range.end}`}
className='rounded-[5px] bg-[var(--surface-5)] py-[2px]'
style={{ boxShadow: '-2px 0 0 var(--surface-5), 2px 0 0 var(--surface-5)' }}
style={{
boxShadow: '-2px 0 0 var(--surface-5), 2px 0 0 var(--surface-5)',
}}
>
<span className='relative'>
<span className='invisible'>{range.token.charAt(0)}</span>
@@ -662,7 +689,7 @@ export function UserInput({
<div
onClick={handleContainerClick}
className={cn(
'relative mx-auto w-full max-w-[42rem] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-[10px] py-[8px] dark:bg-[var(--surface-4)]',
'relative z-10 mx-auto w-full max-w-[42rem] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-[10px] py-[8px] dark:bg-[var(--surface-4)]',
isInitialView && 'shadow-sm'
)}
onDragEnter={files.handleDragEnter}
@@ -818,7 +845,11 @@ export function UserInput({
)}
onMouseEnter={() => setPlusMenuActiveIndex(index)}
onClick={() => {
handleResourceSelect({ type, id: item.id, title: item.name })
handleResourceSelect({
type,
id: item.id,
title: item.name,
})
setPlusMenuOpen(false)
setPlusMenuSearch('')
setPlusMenuActiveIndex(0)

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { Skeleton } from '@/components/emcn'
@@ -19,14 +19,14 @@ import type { ChatContext } from '@/stores/panel'
import {
MessageContent,
MothershipView,
QueuedMessages,
TemplatePrompts,
UserInput,
UserMessageContent,
} from './components'
import { PendingTagIndicator } from './components/message-content/components/special-tags'
import type { FileAttachmentForApi } from './components/user-input/user-input'
import { useAutoScroll, useChat } from './hooks'
import type { MothershipResource, MothershipResourceType } from './types'
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
const logger = createLogger('Home')
@@ -183,8 +183,29 @@ export function Home({ chatId }: HomeProps = {}) {
addResource,
removeResource,
reorderResources,
messageQueue,
removeFromQueue,
sendNow,
editQueuedMessage,
} = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent })
const [editingInputValue, setEditingInputValue] = useState('')
const clearEditingValue = useCallback(() => setEditingInputValue(''), [])
const handleEditQueuedMessage = useCallback(
(id: string) => {
const msg = editQueuedMessage(id)
if (msg) {
setEditingInputValue(msg.content)
}
},
[editQueuedMessage]
)
useEffect(() => {
setEditingInputValue('')
}, [chatId])
useEffect(() => {
wasSendingRef.current = false
if (resolvedChatId) markRead(resolvedChatId)
@@ -272,9 +293,22 @@ export function Home({ chatId }: HomeProps = {}) {
[addResource, handleResourceEvent]
)
const scrollContainerRef = useAutoScroll(isSending)
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isSending)
const hasMessages = messages.length > 0
const initialScrollDoneRef = useRef(false)
useLayoutEffect(() => {
if (!hasMessages) {
initialScrollDoneRef.current = false
return
}
if (initialScrollDoneRef.current) return
if (resources.length > 0 && isResourceCollapsed) return
initialScrollDoneRef.current = true
scrollToBottom()
}, [hasMessages, resources.length, isResourceCollapsed, scrollToBottom])
useEffect(() => {
if (hasMessages) return
@@ -309,7 +343,7 @@ export function Home({ chatId }: HomeProps = {}) {
if (!hasMessages) {
return (
<div className='h-full overflow-y-auto bg-[var(--bg)]'>
<div className='h-full overflow-y-auto bg-[var(--bg)] [scrollbar-gutter:stable]'>
<div className='flex min-h-full flex-col items-center justify-center px-[24px] pb-[2vh]'>
<h1 className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] text-[var(--text-primary)] tracking-[-0.02em]'>
What should we get done
@@ -338,7 +372,7 @@ export function Home({ chatId }: HomeProps = {}) {
<div className='flex h-full min-w-0 flex-1 flex-col'>
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8'
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]'
>
<div className='mx-auto max-w-[42rem] space-y-6'>
{messages.map((msg, index) => {
@@ -406,6 +440,12 @@ export function Home({ chatId }: HomeProps = {}) {
<div className='flex-shrink-0 px-[24px] pb-[16px]'>
<div className='mx-auto max-w-[42rem]'>
<QueuedMessages
messageQueue={messageQueue}
onRemove={removeFromQueue}
onSendNow={sendNow}
onEdit={handleEditQueuedMessage}
/>
<UserInput
onSubmit={handleSubmit}
isSending={isSending}
@@ -413,6 +453,8 @@ export function Home({ chatId }: HomeProps = {}) {
isInitialView={false}
userId={session?.user?.id}
onContextAdd={handleContextAdd}
editValue={editingInputValue}
onEditValueConsumed={clearEditingValue}
/>
</div>
</div>

View File

@@ -12,6 +12,9 @@ const REATTACH_THRESHOLD = 5
* on any upward user gesture (wheel, touch, scrollbar drag). Once detached,
* the user must scroll back to within {@link REATTACH_THRESHOLD} of the
* bottom to re-engage.
*
* Returns `ref` (callback ref for the scroll container) and `scrollToBottom`
* for imperative use after layout-changing events like panel expansion.
*/
export function useAutoScroll(isStreaming: boolean) {
const containerRef = useRef<HTMLDivElement>(null)
@@ -110,5 +113,5 @@ export function useAutoScroll(isStreaming: boolean) {
}
}, [isStreaming, scrollToBottom])
return callbackRef
return { ref: callbackRef, scrollToBottom }
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname } from 'next/navigation'
@@ -28,14 +28,15 @@ import { useFolderStore } from '@/stores/folders/store'
import type { ChatContext } from '@/stores/panel'
import { useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { FileAttachmentForApi } from '../components/user-input/user-input'
import type {
ChatMessage,
ChatMessageAttachment,
ContentBlock,
ContentBlockType,
FileAttachmentForApi,
MothershipResource,
MothershipResourceType,
QueuedMessage,
SSEPayload,
SSEPayloadData,
ToolCallStatus,
@@ -58,6 +59,10 @@ export interface UseChatReturn {
addResource: (resource: MothershipResource) => boolean
removeResource: (resourceType: MothershipResourceType, resourceId: string) => void
reorderResources: (resources: MothershipResource[]) => void
messageQueue: QueuedMessage[]
removeFromQueue: (id: string) => void
sendNow: (id: string) => Promise<void>
editQueuedMessage: (id: string) => QueuedMessage | undefined
}
const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
@@ -101,7 +106,11 @@ function mapStoredToolCall(tc: TaskStoredToolCall): ContentBlock {
displayTitle: resolvedStatus === 'cancelled' ? 'Stopped by user' : undefined,
result:
tc.result != null
? { success: tc.status === 'success', output: tc.result, error: tc.error }
? {
success: tc.status === 'success',
output: tc.result,
error: tc.error,
}
: undefined,
},
}
@@ -252,6 +261,14 @@ export function useChat(
const activeResourceIdRef = useRef(activeResourceId)
activeResourceIdRef.current = activeResourceId
const [messageQueue, setMessageQueue] = useState<QueuedMessage[]>([])
const messageQueueRef = useRef<QueuedMessage[]>([])
useEffect(() => {
messageQueueRef.current = messageQueue
}, [messageQueue])
const sendMessageRef = useRef<UseChatReturn['sendMessage']>(async () => {})
const abortControllerRef = useRef<AbortController | null>(null)
const chatIdRef = useRef<string | undefined>(initialChatId)
const appliedChatIdRef = useRef<string | undefined>(undefined)
@@ -303,6 +320,7 @@ export function useChat(
if (sendingRef.current) {
chatIdRef.current = initialChatId
setResolvedChatId(initialChatId)
setMessageQueue([])
return
}
chatIdRef.current = initialChatId
@@ -313,6 +331,7 @@ export function useChat(
setIsSending(false)
setResources([])
setActiveResourceId(null)
setMessageQueue([])
}, [initialChatId])
useEffect(() => {
@@ -329,6 +348,7 @@ export function useChat(
setIsSending(false)
setResources([])
setActiveResourceId(null)
setMessageQueue([])
}, [isHomePage])
useEffect(() => {
@@ -419,7 +439,9 @@ export function useChat(
const isNewChat = !chatIdRef.current
chatIdRef.current = parsed.chatId
setResolvedChatId(parsed.chatId)
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
queryClient.invalidateQueries({
queryKey: taskKeys.list(workspaceId),
})
if (isNewChat) {
const userMsg = pendingUserMsgRef.current
const activeStreamId = streamIdRef.current
@@ -427,7 +449,13 @@ export function useChat(
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(parsed.chatId), {
id: parsed.chatId,
title: null,
messages: [{ id: userMsg.id, role: 'user', content: userMsg.content }],
messages: [
{
id: userMsg.id,
role: 'user',
content: userMsg.content,
},
],
activeStreamId,
resources: [],
})
@@ -619,7 +647,9 @@ export function useChat(
break
}
case 'title_updated': {
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
queryClient.invalidateQueries({
queryKey: taskKeys.list(workspaceId),
})
break
}
case 'error': {
@@ -689,17 +719,37 @@ export function useChat(
const invalidateChatQueries = useCallback(() => {
const activeChatId = chatIdRef.current
if (activeChatId) {
queryClient.invalidateQueries({ queryKey: taskKeys.detail(activeChatId) })
queryClient.invalidateQueries({
queryKey: taskKeys.detail(activeChatId),
})
}
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
}, [workspaceId, queryClient])
const finalize = useCallback(() => {
sendingRef.current = false
setIsSending(false)
abortControllerRef.current = null
invalidateChatQueries()
}, [invalidateChatQueries])
const finalize = useCallback(
(options?: { error?: boolean }) => {
sendingRef.current = false
setIsSending(false)
abortControllerRef.current = null
invalidateChatQueries()
if (options?.error) {
setMessageQueue([])
return
}
const next = messageQueueRef.current[0]
if (next) {
setMessageQueue((prev) => prev.filter((m) => m.id !== next.id))
const gen = streamGenRef.current
queueMicrotask(() => {
if (streamGenRef.current !== gen) return
sendMessageRef.current(next.content, next.fileAttachments, next.contexts)
})
}
},
[invalidateChatQueries]
)
useEffect(() => {
const activeStreamId = chatHistory?.activeStreamId
@@ -714,7 +764,12 @@ export function useChat(
const assistantId = crypto.randomUUID()
setMessages((prev) => [
...prev,
{ id: assistantId, role: 'assistant' as const, content: '', contentBlocks: [] },
{
id: assistantId,
role: 'assistant' as const,
content: '',
contentBlocks: [],
},
])
const reconnect = async () => {
@@ -745,9 +800,15 @@ export function useChat(
if (!message.trim() || !workspaceId) return
if (sendingRef.current) {
await persistPartialResponse()
const queued: QueuedMessage = {
id: crypto.randomUUID(),
content: message,
fileAttachments,
contexts,
}
setMessageQueue((prev) => [...prev, queued])
return
}
abortControllerRef.current?.abort()
const gen = ++streamGenRef.current
@@ -854,14 +915,20 @@ export function useChat(
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
setError(err instanceof Error ? err.message : 'Failed to send message')
} finally {
if (streamGenRef.current === gen) {
finalize()
finalize({ error: true })
}
return
}
if (streamGenRef.current === gen) {
finalize()
}
},
[workspaceId, queryClient, processSSEStream, finalize, persistPartialResponse]
[workspaceId, queryClient, processSSEStream, finalize]
)
useLayoutEffect(() => {
sendMessageRef.current = sendMessage
})
const stopGeneration = useCallback(async () => {
if (sendingRef.current) {
@@ -943,6 +1010,32 @@ export function useChat(
}
}, [invalidateChatQueries, persistPartialResponse, executionStream])
const removeFromQueue = useCallback((id: string) => {
messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
setMessageQueue((prev) => prev.filter((m) => m.id !== id))
}, [])
const sendNow = useCallback(
async (id: string) => {
const msg = messageQueueRef.current.find((m) => m.id === id)
if (!msg) return
// Eagerly update ref so a rapid second click finds the message already gone
messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
await stopGeneration()
setMessageQueue((prev) => prev.filter((m) => m.id !== id))
await sendMessage(msg.content, msg.fileAttachments, msg.contexts)
},
[stopGeneration, sendMessage]
)
const editQueuedMessage = useCallback((id: string): QueuedMessage | undefined => {
const msg = messageQueueRef.current.find((m) => m.id === id)
if (!msg) return undefined
messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
setMessageQueue((prev) => prev.filter((m) => m.id !== id))
return msg
}, [])
useEffect(() => {
return () => {
streamGenRef.current++
@@ -968,5 +1061,9 @@ export function useChat(
addResource,
removeResource,
reorderResources,
messageQueue,
removeFromQueue,
sendNow,
editQueuedMessage,
}
}

View File

@@ -1,6 +1,25 @@
import type { MothershipResourceType } from '@/lib/copilot/resource-types'
import type { ChatContext } from '@/stores/panel'
export type { MothershipResource, MothershipResourceType } from '@/lib/copilot/resource-types'
export type {
MothershipResource,
MothershipResourceType,
} from '@/lib/copilot/resource-types'
export interface FileAttachmentForApi {
id: string
key: string
filename: string
media_type: string
size: number
}
export interface QueuedMessage {
id: string
content: string
fileAttachments?: FileAttachmentForApi[]
contexts?: ChatContext[]
}
/**
* SSE event types emitted by the Go orchestrator backend.
@@ -16,21 +35,21 @@ export type SSEEventType =
| 'chat_id'
| 'title_updated'
| 'content'
| 'reasoning'
| 'tool_call'
| 'tool_call_delta'
| 'tool_generating'
| 'tool_result'
| 'tool_error'
| 'resource_added'
| 'resource_deleted'
| 'subagent_start'
| 'subagent_end'
| 'structured_result'
| 'subagent_result'
| 'done'
| 'error'
| 'start'
| 'reasoning' // openai reasoning - render as thinking text
| 'tool_call' // tool call name
| 'tool_call_delta' // chunk of tool call
| 'tool_generating' // start a tool call
| 'tool_result' // tool call result
| 'tool_error' // tool call error
| 'resource_added' // add a resource to the chat
| 'resource_deleted' // delete a resource from the chat
| 'subagent_start' // start a subagent
| 'subagent_end' // end a subagent
| 'structured_result' // structured result from a tool call
| 'subagent_result' // result from a subagent
| 'done' // end of the chat
| 'error' // error in the chat
| 'start' // start of the chat
/**
* All tool names observed in the mothership SSE stream, grouped by phase.
@@ -203,38 +222,122 @@ export interface ToolUIMetadata {
* fallback metadata for tools that arrive via `tool_generating` without `ui`.
*/
export const TOOL_UI_METADATA: Partial<Record<MothershipToolName, ToolUIMetadata>> = {
glob: { title: 'Searching files', phaseLabel: 'Workspace', phase: 'workspace' },
grep: { title: 'Searching code', phaseLabel: 'Workspace', phase: 'workspace' },
glob: {
title: 'Searching files',
phaseLabel: 'Workspace',
phase: 'workspace',
},
grep: {
title: 'Searching code',
phaseLabel: 'Workspace',
phase: 'workspace',
},
read: { title: 'Reading file', phaseLabel: 'Workspace', phase: 'workspace' },
search_online: { title: 'Searching online', phaseLabel: 'Search', phase: 'search' },
scrape_page: { title: 'Scraping page', phaseLabel: 'Search', phase: 'search' },
get_page_contents: { title: 'Getting page contents', phaseLabel: 'Search', phase: 'search' },
search_library_docs: { title: 'Searching library docs', phaseLabel: 'Search', phase: 'search' },
manage_mcp_tool: { title: 'Managing MCP tool', phaseLabel: 'Management', phase: 'management' },
manage_skill: { title: 'Managing skill', phaseLabel: 'Management', phase: 'management' },
user_memory: { title: 'Accessing memory', phaseLabel: 'Management', phase: 'management' },
function_execute: { title: 'Running code', phaseLabel: 'Code', phase: 'execution' },
superagent: { title: 'Executing action', phaseLabel: 'Action', phase: 'execution' },
user_table: { title: 'Managing table', phaseLabel: 'Resource', phase: 'resource' },
workspace_file: { title: 'Managing file', phaseLabel: 'Resource', phase: 'resource' },
create_workflow: { title: 'Creating workflow', phaseLabel: 'Resource', phase: 'resource' },
edit_workflow: { title: 'Editing workflow', phaseLabel: 'Resource', phase: 'resource' },
search_online: {
title: 'Searching online',
phaseLabel: 'Search',
phase: 'search',
},
scrape_page: {
title: 'Scraping page',
phaseLabel: 'Search',
phase: 'search',
},
get_page_contents: {
title: 'Getting page contents',
phaseLabel: 'Search',
phase: 'search',
},
search_library_docs: {
title: 'Searching library docs',
phaseLabel: 'Search',
phase: 'search',
},
manage_mcp_tool: {
title: 'Managing MCP tool',
phaseLabel: 'Management',
phase: 'management',
},
manage_skill: {
title: 'Managing skill',
phaseLabel: 'Management',
phase: 'management',
},
user_memory: {
title: 'Accessing memory',
phaseLabel: 'Management',
phase: 'management',
},
function_execute: {
title: 'Running code',
phaseLabel: 'Code',
phase: 'execution',
},
superagent: {
title: 'Executing action',
phaseLabel: 'Action',
phase: 'execution',
},
user_table: {
title: 'Managing table',
phaseLabel: 'Resource',
phase: 'resource',
},
workspace_file: {
title: 'Managing file',
phaseLabel: 'Resource',
phase: 'resource',
},
create_workflow: {
title: 'Creating workflow',
phaseLabel: 'Resource',
phase: 'resource',
},
edit_workflow: {
title: 'Editing workflow',
phaseLabel: 'Resource',
phase: 'resource',
},
build: { title: 'Building', phaseLabel: 'Build', phase: 'subagent' },
run: { title: 'Running', phaseLabel: 'Run', phase: 'subagent' },
deploy: { title: 'Deploying', phaseLabel: 'Deploy', phase: 'subagent' },
auth: { title: 'Connecting credentials', phaseLabel: 'Auth', phase: 'subagent' },
knowledge: { title: 'Managing knowledge', phaseLabel: 'Knowledge', phase: 'subagent' },
knowledge_base: { title: 'Managing knowledge base', phaseLabel: 'Resource', phase: 'resource' },
auth: {
title: 'Connecting credentials',
phaseLabel: 'Auth',
phase: 'subagent',
},
knowledge: {
title: 'Managing knowledge',
phaseLabel: 'Knowledge',
phase: 'subagent',
},
knowledge_base: {
title: 'Managing knowledge base',
phaseLabel: 'Resource',
phase: 'resource',
},
table: { title: 'Managing tables', phaseLabel: 'Table', phase: 'subagent' },
job: { title: 'Managing jobs', phaseLabel: 'Job', phase: 'subagent' },
agent: { title: 'Agent action', phaseLabel: 'Agent', phase: 'subagent' },
custom_tool: { title: 'Creating tool', phaseLabel: 'Tool', phase: 'subagent' },
custom_tool: {
title: 'Creating tool',
phaseLabel: 'Tool',
phase: 'subagent',
},
research: { title: 'Researching', phaseLabel: 'Research', phase: 'subagent' },
plan: { title: 'Planning', phaseLabel: 'Plan', phase: 'subagent' },
debug: { title: 'Debugging', phaseLabel: 'Debug', phase: 'subagent' },
edit: { title: 'Editing workflow', phaseLabel: 'Edit', phase: 'subagent' },
fast_edit: { title: 'Editing workflow', phaseLabel: 'Edit', phase: 'subagent' },
open_resource: { title: 'Opening resource', phaseLabel: 'Resource', phase: 'resource' },
fast_edit: {
title: 'Editing workflow',
phaseLabel: 'Edit',
phase: 'subagent',
},
open_resource: {
title: 'Opening resource',
phaseLabel: 'Resource',
phase: 'resource',
},
}
export interface SSEPayloadUI {

View File

@@ -554,7 +554,7 @@ export function DocumentTagsModal({
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={saveDocumentTag}
className='flex-1'
disabled={!canSaveTag}
@@ -718,7 +718,7 @@ export function DocumentTagsModal({
</Button>
)}
<Button
variant='tertiary'
variant='primary'
onClick={saveDocumentTag}
className='flex-1'
disabled={

View File

@@ -1,7 +1,7 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { ArrowLeft, Loader2, Plus, Search } from 'lucide-react'
import { ArrowLeft, ArrowLeftRight, Loader2, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -17,6 +17,7 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Tooltip,
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import {
@@ -24,11 +25,14 @@ import {
getProviderIdFromServiceId,
type OAuthProvider,
} from '@/lib/oauth'
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { getDependsOnFields } from '@/blocks/utils'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
import type { ConnectorConfig } from '@/connectors/types'
import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types'
import { useCreateConnector } from '@/hooks/queries/kb/connectors'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
import type { SelectorKey } from '@/hooks/selectors/types'
const SYNC_INTERVALS = [
{ label: 'Every hour', value: 60 },
@@ -38,6 +42,8 @@ const SYNC_INTERVALS = [
{ label: 'Manual only', value: 0 },
] as const
const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_REGISTRY)
interface AddConnectorModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -55,8 +61,10 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
const [disabledTagIds, setDisabledTagIds] = useState<Set<string>>(() => new Set())
const [error, setError] = useState<string | null>(null)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [canonicalModes, setCanonicalModes] = useState<Record<string, 'basic' | 'advanced'>>({})
const [apiKeyValue, setApiKeyValue] = useState('')
const [apiKeyFocused, setApiKeyFocused] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const { workspaceId } = useParams<{ workspaceId: string }>()
@@ -81,17 +89,126 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
const effectiveCredentialId =
selectedCredentialId ?? (credentials.length === 1 ? credentials[0].id : null)
const canonicalGroups = useMemo(() => {
if (!connectorConfig) return new Map<string, ConnectorConfigField[]>()
const groups = new Map<string, ConnectorConfigField[]>()
for (const field of connectorConfig.configFields) {
if (field.canonicalParamId) {
const existing = groups.get(field.canonicalParamId)
if (existing) {
existing.push(field)
} else {
groups.set(field.canonicalParamId, [field])
}
}
}
return groups
}, [connectorConfig])
const dependentFieldIds = useMemo(() => {
if (!connectorConfig) return new Map<string, string[]>()
const map = new Map<string, string[]>()
for (const field of connectorConfig.configFields) {
const deps = getDependsOnFields(field.dependsOn)
for (const dep of deps) {
const existing = map.get(dep) ?? []
existing.push(field.id)
map.set(dep, existing)
}
}
for (const group of canonicalGroups.values()) {
const allDependents = new Set<string>()
for (const field of group) {
for (const dep of map.get(field.id) ?? []) {
allDependents.add(dep)
const depField = connectorConfig.configFields.find((f) => f.id === dep)
if (depField?.canonicalParamId) {
for (const sibling of canonicalGroups.get(depField.canonicalParamId) ?? []) {
allDependents.add(sibling.id)
}
}
}
}
if (allDependents.size > 0) {
for (const field of group) {
map.set(field.id, [...allDependents])
}
}
}
return map
}, [connectorConfig, canonicalGroups])
const handleSelectType = (type: string) => {
setSelectedType(type)
setSourceConfig({})
setSelectedCredentialId(null)
setApiKeyValue('')
setApiKeyFocused(false)
setDisabledTagIds(new Set())
setCanonicalModes({})
setError(null)
setSearchTerm('')
setStep('configure')
}
const handleFieldChange = useCallback(
(fieldId: string, value: string) => {
setSourceConfig((prev) => {
const next = { ...prev, [fieldId]: value }
const toClear = dependentFieldIds.get(fieldId)
if (toClear) {
for (const depId of toClear) {
next[depId] = ''
}
}
return next
})
},
[dependentFieldIds]
)
const toggleCanonicalMode = useCallback((canonicalId: string) => {
setCanonicalModes((prev) => ({
...prev,
[canonicalId]: prev[canonicalId] === 'advanced' ? 'basic' : 'advanced',
}))
}, [])
const isFieldVisible = useCallback(
(field: ConnectorConfigField): boolean => {
if (!field.canonicalParamId || !field.mode) return true
const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic'
return field.mode === activeMode
},
[canonicalModes]
)
const resolveSourceConfig = useCallback((): Record<string, string> => {
const resolved: Record<string, string> = {}
const processedCanonicals = new Set<string>()
if (!connectorConfig) return resolved
for (const field of connectorConfig.configFields) {
if (field.canonicalParamId) {
if (processedCanonicals.has(field.canonicalParamId)) continue
processedCanonicals.add(field.canonicalParamId)
const group = canonicalGroups.get(field.canonicalParamId)
if (!group) continue
const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic'
const activeField = group.find((f) => f.mode === activeMode) ?? group[0]
const value = sourceConfig[activeField.id]
if (value) resolved[field.canonicalParamId] = value
} else {
if (sourceConfig[field.id]) resolved[field.id] = sourceConfig[field.id]
}
}
return resolved
}, [connectorConfig, canonicalGroups, canonicalModes, sourceConfig])
const canSubmit = useMemo(() => {
if (!connectorConfig) return false
if (isApiKeyMode) {
@@ -99,20 +216,32 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
} else {
if (!effectiveCredentialId) return false
}
return connectorConfig.configFields
.filter((f) => f.required)
.every((f) => sourceConfig[f.id]?.trim())
}, [connectorConfig, isApiKeyMode, apiKeyValue, effectiveCredentialId, sourceConfig])
for (const field of connectorConfig.configFields) {
if (!field.required) continue
if (!isFieldVisible(field)) continue
if (!sourceConfig[field.id]?.trim()) return false
}
return true
}, [
connectorConfig,
isApiKeyMode,
apiKeyValue,
effectiveCredentialId,
sourceConfig,
isFieldVisible,
])
const handleSubmit = () => {
if (!selectedType || !canSubmit) return
setError(null)
const resolvedConfig = resolveSourceConfig()
const finalSourceConfig =
disabledTagIds.size > 0
? { ...sourceConfig, disabledTagIds: Array.from(disabledTagIds) }
: sourceConfig
? { ...resolvedConfig, disabledTagIds: Array.from(disabledTagIds) }
: resolvedConfig
createConnector(
{
@@ -162,21 +291,19 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
setShowOAuthModal(true)
}, [connectorConfig, connectorProviderId, workspaceId, session?.user?.name])
const connectorEntries = Object.entries(CONNECTOR_REGISTRY)
const filteredEntries = useMemo(() => {
const term = searchTerm.toLowerCase().trim()
if (!term) return connectorEntries
return connectorEntries.filter(
if (!term) return CONNECTOR_ENTRIES
return CONNECTOR_ENTRIES.filter(
([, config]) =>
config.name.toLowerCase().includes(term) || config.description.toLowerCase().includes(term)
)
}, [connectorEntries, searchTerm])
}, [searchTerm])
return (
<>
<Modal open={open} onOpenChange={(val) => !isCreating && onOpenChange(val)}>
<ModalContent size='md'>
<ModalContent size='md' className='h-[80vh] max-h-[560px]'>
<ModalHeader>
{step === 'configure' && (
<Button
@@ -205,7 +332,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='max-h-[400px] min-h-0 overflow-y-auto'>
<div className='min-h-[400px] overflow-y-auto'>
<div className='flex flex-col gap-[2px]'>
{filteredEntries.map(([type, config]) => (
<ConnectorTypeCard
@@ -216,7 +343,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
))}
{filteredEntries.length === 0 && (
<div className='py-[16px] text-center text-[14px] text-[var(--text-muted)]'>
{connectorEntries.length === 0
{CONNECTOR_ENTRIES.length === 0
? 'No connectors available.'
: `No sources found matching "${searchTerm}"`}
</div>
@@ -235,10 +362,12 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
: 'API Key'}
</Label>
<Input
type='password'
type={apiKeyFocused ? 'text' : 'password'}
autoComplete='new-password'
value={apiKeyValue}
onChange={(e) => setApiKeyValue(e.target.value)}
onFocus={() => setApiKeyFocused(true)}
onBlur={() => setApiKeyFocused(false)}
placeholder={
connectorConfig.auth.mode === 'apiKey' && connectorConfig.auth.placeholder
? connectorConfig.auth.placeholder
@@ -287,41 +416,76 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
)}
{/* Config fields */}
{connectorConfig.configFields.map((field) => (
<div key={field.id} className='flex flex-col gap-[4px]'>
<Label>
{field.title}
{field.required && (
<span className='ml-[2px] text-[var(--text-error)]'>*</span>
{connectorConfig.configFields.map((field) => {
if (!isFieldVisible(field)) return null
const canonicalId = field.canonicalParamId
const hasCanonicalPair =
canonicalId && (canonicalGroups.get(canonicalId)?.length ?? 0) === 2
return (
<div key={field.id} className='flex flex-col gap-[4px]'>
<div className='flex items-center justify-between'>
<Label>
{field.title}
{field.required && (
<span className='ml-[2px] text-[var(--text-error)]'>*</span>
)}
</Label>
{hasCanonicalPair && canonicalId && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='flex h-[18px] w-[18px] items-center justify-center rounded-[3px] text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-secondary)]'
onClick={() => toggleCanonicalMode(canonicalId)}
>
<ArrowLeftRight className='h-[12px] w-[12px]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{field.mode === 'basic'
? 'Switch to manual input'
: 'Switch to selector'}
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
{field.description && (
<p className='text-[11px] text-[var(--text-muted)]'>{field.description}</p>
)}
</Label>
{field.description && (
<p className='text-[11px] text-[var(--text-muted)]'>{field.description}</p>
)}
{field.type === 'dropdown' && field.options ? (
<Combobox
size='sm'
options={field.options.map((opt) => ({
label: opt.label,
value: opt.id,
}))}
value={sourceConfig[field.id] || undefined}
onChange={(value) =>
setSourceConfig((prev) => ({ ...prev, [field.id]: value }))
}
placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`}
/>
) : (
<Input
value={sourceConfig[field.id] || ''}
onChange={(e) =>
setSourceConfig((prev) => ({ ...prev, [field.id]: e.target.value }))
}
placeholder={field.placeholder}
/>
)}
</div>
))}
{field.type === 'selector' && field.selectorKey ? (
<ConnectorSelectorField
field={field as ConnectorConfigField & { selectorKey: SelectorKey }}
value={sourceConfig[field.id] || ''}
onChange={(value) => handleFieldChange(field.id, value)}
credentialId={effectiveCredentialId}
sourceConfig={sourceConfig}
configFields={connectorConfig.configFields}
canonicalModes={canonicalModes}
disabled={isCreating}
/>
) : field.type === 'dropdown' && field.options ? (
<Combobox
size='sm'
options={field.options.map((opt) => ({
label: opt.label,
value: opt.id,
}))}
value={sourceConfig[field.id] || undefined}
onChange={(value) => handleFieldChange(field.id, value)}
placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`}
/>
) : (
<Input
value={sourceConfig[field.id] || ''}
onChange={(e) => handleFieldChange(field.id, e.target.value)}
placeholder={field.placeholder}
/>
)}
</div>
)
})}
{/* Tag definitions (opt-out) */}
{connectorConfig.tagDefinitions && connectorConfig.tagDefinitions.length > 0 && (
@@ -345,6 +509,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
>
<Checkbox
checked={!disabledTagIds.has(tagDef.id)}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => {
setDisabledTagIds((prev) => {
const next = new Set(prev)
@@ -393,7 +558,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
<Button variant='default' onClick={() => onOpenChange(false)} disabled={isCreating}>
Cancel
</Button>
<Button variant='tertiary' onClick={handleSubmit} disabled={!canSubmit || isCreating}>
<Button variant='primary' onClick={handleSubmit} disabled={!canSubmit || isCreating}>
{isCreating ? (
<>
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />

View File

@@ -0,0 +1,119 @@
'use client'
import { useMemo } from 'react'
import { Loader2 } from 'lucide-react'
import { Combobox, type ComboboxOption } from '@/components/emcn'
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
import { getDependsOnFields } from '@/blocks/utils'
import type { ConnectorConfigField } from '@/connectors/types'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
interface ConnectorSelectorFieldProps {
field: ConnectorConfigField & { selectorKey: SelectorKey }
value: string
onChange: (value: string) => void
credentialId: string | null
sourceConfig: Record<string, string>
configFields: ConnectorConfigField[]
canonicalModes: Record<string, 'basic' | 'advanced'>
disabled?: boolean
}
export function ConnectorSelectorField({
field,
value,
onChange,
credentialId,
sourceConfig,
configFields,
canonicalModes,
disabled,
}: ConnectorSelectorFieldProps) {
const context = useMemo<SelectorContext>(() => {
const ctx: SelectorContext = {}
if (credentialId) ctx.oauthCredential = credentialId
for (const depFieldId of getDependsOnFields(field.dependsOn)) {
const depField = configFields.find((f) => f.id === depFieldId)
const canonicalId = depField?.canonicalParamId ?? depFieldId
const depValue = resolveDepValue(depFieldId, configFields, canonicalModes, sourceConfig)
if (depValue && SELECTOR_CONTEXT_FIELDS.has(canonicalId as keyof SelectorContext)) {
ctx[canonicalId as keyof SelectorContext] = depValue
}
}
return ctx
}, [credentialId, field.dependsOn, sourceConfig, configFields, canonicalModes])
const depsResolved = useMemo(() => {
if (!field.dependsOn) return true
const deps = Array.isArray(field.dependsOn) ? field.dependsOn : (field.dependsOn.all ?? [])
return deps.every((depId) =>
Boolean(resolveDepValue(depId, configFields, canonicalModes, sourceConfig)?.trim())
)
}, [field.dependsOn, sourceConfig, configFields, canonicalModes])
const isEnabled = !disabled && !!credentialId && depsResolved
const { data: options = [], isLoading } = useSelectorOptions(field.selectorKey, {
context,
enabled: isEnabled,
})
const comboboxOptions = useMemo<ComboboxOption[]>(
() => options.map((opt) => ({ label: opt.label, value: opt.id })),
[options]
)
if (isLoading && isEnabled) {
return (
<div className='flex items-center gap-2 rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] text-[var(--text-muted)] text-sm'>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
Loading...
</div>
)
}
return (
<Combobox
options={comboboxOptions}
value={value || undefined}
onChange={onChange}
placeholder={
!credentialId
? 'Connect an account first'
: !depsResolved
? `Select ${getDependencyLabel(field, configFields)} first`
: field.placeholder || `Select ${field.title.toLowerCase()}`
}
disabled={disabled || !credentialId || !depsResolved}
/>
)
}
function resolveDepValue(
depFieldId: string,
configFields: ConnectorConfigField[],
canonicalModes: Record<string, 'basic' | 'advanced'>,
sourceConfig: Record<string, string>
): string {
const depField = configFields.find((f) => f.id === depFieldId)
if (!depField?.canonicalParamId) return sourceConfig[depFieldId] ?? ''
const activeMode = canonicalModes[depField.canonicalParamId] ?? 'basic'
if (depField.mode === activeMode) return sourceConfig[depFieldId] ?? ''
const activeField = configFields.find(
(f) => f.canonicalParamId === depField.canonicalParamId && f.mode === activeMode
)
return activeField ? (sourceConfig[activeField.id] ?? '') : (sourceConfig[depFieldId] ?? '')
}
function getDependencyLabel(
field: ConnectorConfigField,
configFields: ConnectorConfigField[]
): string {
const deps = getDependsOnFields(field.dependsOn)
const depField = deps.length > 0 ? configFields.find((f) => f.id === deps[0]) : undefined
return depField?.title?.toLowerCase() ?? 'dependency'
}

View File

@@ -347,7 +347,7 @@ export function AddDocumentsModal({
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
type='button'
onClick={handleUpload}
disabled={files.length === 0 || isUploading}

View File

@@ -387,7 +387,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={saveTagDefinition}
className='flex-1'
disabled={

View File

@@ -130,8 +130,6 @@ export function ConnectorsSection({
return (
<div className='mt-[16px]'>
<h2 className='font-medium text-[14px] text-[var(--text-secondary)]'>Connected Sources</h2>
{error && (
<p className='mt-[8px] text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>
)}

View File

@@ -161,7 +161,7 @@ export function EditConnectorModal({
<Button variant='default' onClick={() => onOpenChange(false)} disabled={isSaving}>
Cancel
</Button>
<Button variant='tertiary' onClick={handleSave} disabled={!hasChanges || isSaving}>
<Button variant='primary' onClick={handleSave} disabled={!hasChanges || isSaving}>
{isSaving ? (
<>
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />

View File

@@ -124,7 +124,7 @@ export function RenameDocumentModal({
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
type='submit'
disabled={isSubmitting || !name?.trim() || name.trim() === initialName}
>

View File

@@ -522,7 +522,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
type='submit'
disabled={isSubmitting || !nameValue?.trim()}
>

View File

@@ -159,7 +159,7 @@ export function EditKnowledgeBaseModal({
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
type='submit'
disabled={isSubmitting || !nameValue?.trim() || !isDirty}
>

View File

@@ -1,6 +1,5 @@
'use client'
import { ToastProvider } from '@/components/emcn'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
@@ -9,7 +8,7 @@ import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/side
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
<ToastProvider>
<>
<SettingsLoader />
<ProviderModelsLoader />
<GlobalCommandsProvider>
@@ -26,6 +25,6 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
</WorkspacePermissionsProvider>
</div>
</GlobalCommandsProvider>
</ToastProvider>
</>
)
}

View File

@@ -580,7 +580,7 @@ export const NotificationSettings = memo(function NotificationSettings({
<div className='flex flex-shrink-0 items-center gap-[8px]'>
<Button
variant='tertiary'
variant='primary'
onClick={() => handleTest(subscription.id)}
disabled={testNotification.isPending && testStatus?.id !== subscription.id}
>
@@ -1235,7 +1235,7 @@ export const NotificationSettings = memo(function NotificationSettings({
</Button>
)}
<Button
variant='tertiary'
variant='primary'
onClick={handleSave}
disabled={createNotification.isPending || updateNotification.isPending}
>
@@ -1254,7 +1254,7 @@ export const NotificationSettings = memo(function NotificationSettings({
resetForm()
setShowForm(true)
}}
variant='tertiary'
variant='primary'
disabled={isLoading}
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />

View File

@@ -541,7 +541,7 @@ export function ScheduleModal({ open, onOpenChange, workspaceId, schedule }: Sch
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={handleSubmit}
disabled={
!isFormValid || createScheduleMutation.isPending || updateScheduleMutation.isPending

View File

@@ -149,7 +149,7 @@ export function ApiKeys() {
e.currentTarget.blur()
setIsCreateDialogOpen(true)
}}
variant='tertiary'
variant='primary'
disabled={createButtonDisabled}
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />

View File

@@ -190,7 +190,7 @@ export function CreateApiKeyModal({
</Button>
<Button
type='button'
variant='tertiary'
variant='primary'
onClick={handleCreateKey}
disabled={
!keyName.trim() ||

View File

@@ -277,11 +277,7 @@ export function BYOK() {
</Button>
</div>
) : (
<Button
variant='primary'
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
onClick={() => openEditModal(provider.id)}
>
<Button variant='primary' onClick={() => openEditModal(provider.id)}>
Add Key
</Button>
)}
@@ -391,7 +387,7 @@ export function BYOK() {
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={handleSave}
disabled={!apiKeyInput.trim() || upsertKey.isPending}
>

View File

@@ -200,7 +200,7 @@ export function Copilot() {
setIsCreateDialogOpen(true)
setCreateError(null)
}}
variant='tertiary'
variant='primary'
disabled={isLoading}
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
@@ -302,7 +302,7 @@ export function Copilot() {
</Button>
<Button
type='button'
variant='tertiary'
variant='primary'
onClick={handleCreateKey}
disabled={!newKeyName.trim() || generateKey.isPending}
>

View File

@@ -628,7 +628,7 @@ export function CredentialSets() {
/>
</div>
{canManageCredentialSets && (
<Button variant='tertiary' onClick={() => setShowCreateModal(true)}>
<Button variant='primary' onClick={() => setShowCreateModal(true)}>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Create
</Button>
@@ -671,7 +671,7 @@ export function CredentialSets() {
</div>
</div>
<Button
variant='tertiary'
variant='primary'
onClick={() => handleAcceptInvitation(invitation.token)}
disabled={acceptInvitation.isPending}
>
@@ -843,7 +843,7 @@ export function CredentialSets() {
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={handleCreateCredentialSet}
disabled={!newSetName.trim() || createCredentialSet.isPending}
>

View File

@@ -1311,7 +1311,7 @@ export function CredentialsManager() {
</Button>
{isSelectedAdmin && (
<Button
variant='tertiary'
variant='primary'
onClick={handleSaveDetails}
disabled={!isDetailsDirty || isSavingDetails}
>
@@ -1414,7 +1414,7 @@ export function CredentialsManager() {
<Button
onClick={handleSave}
disabled={isLoading || !hasChanges || hasConflicts || hasInvalidKeys}
variant='tertiary'
variant='primary'
className={`${hasConflicts || hasInvalidKeys ? 'cursor-not-allowed opacity-50' : ''}`}
>
Save

View File

@@ -101,7 +101,7 @@ export function CustomTools() {
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='tertiary'>
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='primary'>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>

View File

@@ -50,7 +50,7 @@ export function Debug() {
disabled={importWorkflow.isPending}
/>
<Button
variant='tertiary'
variant='primary'
onClick={handleImport}
disabled={importWorkflow.isPending || !workflowId.trim()}
>

View File

@@ -515,7 +515,7 @@ export function General() {
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={handleResetPasswordConfirm}
disabled={resetPassword.isPending || resetPassword.isSuccess}
>

View File

@@ -108,7 +108,7 @@ export function InboxEnableToggle() {
<Button variant='default' onClick={() => setIsEnableOpen(false)}>
Cancel
</Button>
<Button variant='tertiary' onClick={handleEnable} disabled={toggleInbox.isPending}>
<Button variant='primary' onClick={handleEnable} disabled={toggleInbox.isPending}>
{toggleInbox.isPending ? 'Enabling...' : 'Enable'}
</Button>
</ModalFooter>

View File

@@ -282,7 +282,7 @@ export function InboxSettingsTab() {
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={handleAddSender}
disabled={!newSenderEmail.trim() || addSender.isPending}
>

View File

@@ -41,7 +41,7 @@ export function Inbox() {
</p>
</div>
<Button
variant='tertiary'
variant='primary'
onClick={() => router.push(`/workspace/${workspaceId}/settings/subscription`)}
>
Upgrade to Max

View File

@@ -678,7 +678,7 @@ export function IntegrationsManager() {
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={handleCreateCredential}
disabled={
!createOAuthProviderId ||
@@ -1027,7 +1027,7 @@ export function IntegrationsManager() {
</Button>
{isSelectedAdmin && (
<Button
variant='tertiary'
variant='primary'
onClick={handleSaveDetails}
disabled={!isDetailsDirty || isSavingDetails}
>
@@ -1066,7 +1066,7 @@ export function IntegrationsManager() {
<Button
onClick={() => setShowCreateModal(true)}
disabled={credentialsLoading}
variant='tertiary'
variant='primary'
>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Connect

View File

@@ -719,12 +719,12 @@ export function McpServerFormModal({
<Button
onClick={handleSubmitJson}
disabled={isSubmitting || !jsonInput.trim()}
variant='tertiary'
variant='primary'
>
{isSubmitting ? 'Adding...' : submitLabel}
</Button>
) : (
<Button onClick={handleSubmitForm} disabled={isSubmitDisabled} variant='tertiary'>
<Button onClick={handleSubmitForm} disabled={isSubmitDisabled} variant='primary'>
{isSubmitting ? (mode === 'add' ? 'Adding...' : 'Saving...') : submitLabel}
</Button>
)}

View File

@@ -637,11 +637,7 @@ export function MCP({ initialServerId }: MCPProps) {
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button
onClick={() => setShowAddModal(true)}
variant='tertiary'
disabled={serversLoading}
>
<Button onClick={() => setShowAddModal(true)} variant='primary' disabled={serversLoading}>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>

View File

@@ -303,7 +303,7 @@ export function RecentlyDeleted() {
<div className='flex shrink-0 items-center gap-[8px]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>Restored</span>
<Button
variant='default'
variant='primary'
size='sm'
onClick={() =>
router.push(
@@ -316,7 +316,7 @@ export function RecentlyDeleted() {
</div>
) : (
<Button
variant='default'
variant='primary'
size='sm'
disabled={isRestoring}
onClick={() => handleRestore(resource)}

View File

@@ -210,7 +210,7 @@ export function SkillModal({
<Button variant='default' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant='tertiary' onClick={handleSave} disabled={saving || !hasChanges}>
<Button variant='primary' onClick={handleSave} disabled={saving || !hasChanges}>
{saving ? 'Saving...' : initialValues ? 'Update' : 'Create'}
</Button>
</div>

View File

@@ -95,7 +95,7 @@ export function Skills() {
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='tertiary'>
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='primary'>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>

View File

@@ -160,7 +160,7 @@ export function CreditBalance({
</Button>
</ModalClose>
<Button
variant='tertiary'
variant='primary'
onClick={handlePurchase}
disabled={purchaseCredits.isPending || !amount}
>

View File

@@ -245,7 +245,7 @@ function CreditPlanCard({
{isCancelledAtPeriodEnd ? 'Restore Subscription' : 'Manage plan'}
</Button>
) : (
<Button onClick={onButtonClick} className='w-full' variant='tertiary'>
<Button onClick={onButtonClick} className='w-full' variant='primary'>
{buttonText}
</Button>
)}
@@ -1134,7 +1134,7 @@ function TeamPlanModal({ open, onOpenChange, isAnnual, onConfirm }: TeamPlanModa
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={() => onConfirm(selectedTier, selectedSeats)}
disabled={selectedSeats < 1}
>
@@ -1291,7 +1291,7 @@ function ManagePlanModal({
</span>
</div>
<Button
variant='tertiary'
variant='primary'
className='ml-[12px] shrink-0'
onClick={action.onClick}
disabled={action.disabled}
@@ -1312,7 +1312,7 @@ function ManagePlanModal({
<Button variant='default' onClick={() => onOpenChange(false)}>
Close
</Button>
<Button variant='tertiary' onClick={onRestore}>
<Button variant='primary' onClick={onRestore}>
Restore Subscription
</Button>
</>

View File

@@ -239,7 +239,7 @@ export function MemberInvitationCard({
</DropdownMenuContent>
</DropdownMenu>
<Button
variant='tertiary'
variant='primary'
onClick={() => onInviteMember()}
disabled={!hasValidEmails || isInviting || !hasAvailableSeats}
>

View File

@@ -111,7 +111,7 @@ export function NoOrganizationView({
)}
<div className='flex justify-end'>
<Button
variant='tertiary'
variant='primary'
onClick={onCreateOrganization}
disabled={!orgName || !orgSlug || isCreatingOrg}
>
@@ -193,7 +193,7 @@ export function NoOrganizationView({
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={onCreateOrganization}
disabled={isCreatingOrg || !orgName.trim()}
>
@@ -217,7 +217,7 @@ export function NoOrganizationView({
</div>
<div>
<Button variant='tertiary' onClick={() => navigateToSettings({ section: 'subscription' })}>
<Button variant='primary' onClick={() => navigateToSettings({ section: 'subscription' })}>
Upgrade to Team Plan
</Button>
</div>

View File

@@ -72,7 +72,7 @@ export function TeamSeatsOverview({
</p>
</div>
<Button
variant='tertiary'
variant='primary'
onClick={() => {
onConfirmTeamUpgrade(2)
}}

View File

@@ -135,7 +135,7 @@ export function TeamSeats({
<Tooltip.Trigger asChild>
<span>
<Button
variant='tertiary'
variant='primary'
onClick={() => onConfirm(selectedSeats)}
disabled={
isLoading ||

View File

@@ -505,7 +505,7 @@ export function TemplateProfile() {
<Button
onClick={handleSubmit}
disabled={saveStatus === 'saving' || !isFormValid}
variant='tertiary'
variant='primary'
>
{saveStatus === 'saving' ? 'Saving...' : saveStatus === 'saved' ? 'Saved' : 'Save'}
</Button>

View File

@@ -349,7 +349,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
<Tooltip.Trigger asChild>
<div className='inline-flex'>
<Button
variant='tertiary'
variant='primary'
onClick={() => setShowAddWorkflow(true)}
disabled
>
@@ -364,7 +364,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
</Tooltip.Root>
) : (
<Button
variant='tertiary'
variant='primary'
onClick={() => setShowAddWorkflow(true)}
disabled={!canAddWorkflow}
>
@@ -480,7 +480,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
workflows via the MCP block.
</p>
<Button
variant='tertiary'
variant='primary'
className='self-start'
disabled={addToWorkspaceMutation.isPending || addedToWorkspace}
onClick={async () => {
@@ -739,7 +739,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={async () => {
if (!toolToView) return
try {
@@ -859,7 +859,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={handleAddWorkflow}
disabled={!selectedWorkflowId || addToolMutation.isPending}
>
@@ -920,7 +920,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={handleSaveServerEdit}
disabled={
!editServerName.trim() ||
@@ -1059,7 +1059,7 @@ export function WorkflowMcpServers() {
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button onClick={() => setShowAddModal(true)} disabled={isLoading} variant='tertiary'>
<Button onClick={() => setShowAddModal(true)} disabled={isLoading} variant='primary'>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
@@ -1203,7 +1203,7 @@ export function WorkflowMcpServers() {
<Button
onClick={handleCreateServer}
disabled={!isFormValid || createServerMutation.isPending}
variant='tertiary'
variant='primary'
>
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
</Button>

View File

@@ -221,7 +221,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
</Button>
<Button
type='button'
variant='tertiary'
variant='primary'
onClick={handleFormSubmit}
disabled={isSubmitting}
className='min-w-[120px]'

View File

@@ -1,8 +1,10 @@
import { memo, useCallback, useEffect, useRef } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { toast, useToast } from '@/components/emcn'
import { X } from 'lucide-react'
import { Button, Tooltip } from '@/components/emcn'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
type Notification,
type NotificationAction,
@@ -12,6 +14,13 @@ import {
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('Notifications')
const MAX_VISIBLE_NOTIFICATIONS = 4
const STACK_OFFSET_PX = 3
const AUTO_DISMISS_MS = 10000
const EXIT_ANIMATION_MS = 200
const RING_RADIUS = 5.5
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
const ACTION_LABELS: Record<NotificationAction['type'], string> = {
copilot: 'Fix in Copilot',
@@ -19,99 +28,120 @@ const ACTION_LABELS: Record<NotificationAction['type'], string> = {
'unlock-workflow': 'Unlock Workflow',
} as const
function executeNotificationAction(action: NotificationAction) {
switch (action.type) {
case 'copilot':
openCopilotWithMessage(action.message)
break
case 'refresh':
window.location.reload()
break
case 'unlock-workflow':
window.dispatchEvent(new CustomEvent('unlock-workflow'))
break
default:
logger.warn('Unknown action type', { actionType: action.type })
}
function isAutoDismissable(n: Notification): boolean {
return n.level === 'error' && !!n.workflowId
}
function notificationToToast(n: Notification, removeNotification: (id: string) => void) {
const toastAction = n.action
? {
label: ACTION_LABELS[n.action.type] ?? 'Take action',
onClick: () => {
executeNotificationAction(n.action!)
removeNotification(n.id)
},
}
: undefined
return {
message: n.message,
variant: n.level === 'error' ? ('error' as const) : ('default' as const),
action: toastAction,
duration: n.level === 'error' && n.workflowId ? 10_000 : 0,
}
function CountdownRing({ onPause }: { onPause: () => void }) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onPause}
aria-label='Keep notifications visible'
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
>
<svg
width='14'
height='14'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
>
<circle cx='8' cy='8' r={RING_RADIUS} stroke='var(--border)' strokeWidth='1.5' />
<circle
cx='8'
cy='8'
r={RING_RADIUS}
stroke='var(--text-icon)'
strokeWidth='1.5'
strokeLinecap='round'
strokeDasharray={RING_CIRCUMFERENCE}
style={{
animation: `notification-countdown ${AUTO_DISMISS_MS}ms linear forwards`,
}}
/>
</svg>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Keep visible</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
/**
* Headless bridge that syncs the notification Zustand store into the toast system.
* Notifications display component.
* Positioned in the bottom-right workspace area, reactive to panel width and terminal height.
* Shows both global notifications and workflow-specific notifications.
*
* Watches for new notifications scoped to the active workflow and shows them as toasts.
* When a toast is dismissed, the corresponding notification is removed from the store.
* Workflow error notifications auto-dismiss after {@link AUTO_DISMISS_MS}ms with a countdown
* ring. Clicking the ring pauses all timers until the notification stack clears.
*/
export const Notifications = memo(function Notifications() {
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const allNotifications = useNotificationStore((state) => state.notifications)
const removeNotification = useNotificationStore((state) => state.removeNotification)
const clearNotifications = useNotificationStore((state) => state.clearNotifications)
const { dismissAll } = useToast()
const shownIdsRef = useRef(new Set<string>())
const visibleNotifications = useMemo(() => {
if (!activeWorkflowId) return []
return allNotifications
.filter((n) => !n.workflowId || n.workflowId === activeWorkflowId)
.slice(0, MAX_VISIBLE_NOTIFICATIONS)
}, [allNotifications, activeWorkflowId])
const showNotification = useCallback(
(n: Notification) => {
if (shownIdsRef.current.has(n.id)) return
shownIdsRef.current.add(n.id)
/**
* Executes a notification action and handles side effects.
*
* @param notificationId - The ID of the notification whose action is executed.
* @param action - The action configuration to execute.
*/
const executeAction = useCallback(
(notificationId: string, action: NotificationAction) => {
try {
logger.info('Executing notification action', {
notificationId,
actionType: action.type,
messageLength: action.message.length,
})
const input = notificationToToast(n, removeNotification)
toast(input)
switch (action.type) {
case 'copilot':
openCopilotWithMessage(action.message)
break
case 'refresh':
window.location.reload()
break
case 'unlock-workflow':
window.dispatchEvent(new CustomEvent('unlock-workflow'))
break
default:
logger.warn('Unknown action type', { notificationId, actionType: action.type })
}
logger.info('Notification shown as toast', {
id: n.id,
level: n.level,
workflowId: n.workflowId,
})
removeNotification(notificationId)
} catch (error) {
logger.error('Failed to execute notification action', {
notificationId,
actionType: action.type,
error,
})
}
},
[removeNotification]
)
useEffect(() => {
if (!activeWorkflowId) return
const visible = allNotifications.filter(
(n) => !n.workflowId || n.workflowId === activeWorkflowId
)
for (const n of visible) {
showNotification(n)
}
const currentIds = new Set(allNotifications.map((n) => n.id))
for (const id of shownIdsRef.current) {
if (!currentIds.has(id)) {
shownIdsRef.current.delete(id)
}
}
}, [allNotifications, activeWorkflowId, showNotification])
useRegisterGlobalCommands(() =>
createCommands([
{
id: 'clear-notifications',
handler: () => {
clearNotifications(activeWorkflowId ?? undefined)
dismissAll()
},
overrides: {
allowInEditable: false,
@@ -120,5 +150,144 @@ export const Notifications = memo(function Notifications() {
])
)
return null
const preventZoomRef = usePreventZoom()
const [isPaused, setIsPaused] = useState(false)
const isPausedRef = useRef(false)
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
const pauseAll = useCallback(() => {
setIsPaused(true)
isPausedRef.current = true
setExitingIds(new Set())
for (const timer of timersRef.current.values()) clearTimeout(timer)
timersRef.current.clear()
}, [])
/**
* Manages per-notification dismiss timers.
* Resets pause state when the notification stack empties so new arrivals get fresh timers.
*/
useEffect(() => {
isPausedRef.current = isPaused
}, [isPaused])
useEffect(() => {
if (visibleNotifications.length === 0) {
if (isPaused) setIsPaused(false)
for (const timer of timersRef.current.values()) clearTimeout(timer)
timersRef.current.clear()
return
}
if (isPaused) return
const timers = timersRef.current
const activeIds = new Set<string>()
for (const n of visibleNotifications) {
if (!isAutoDismissable(n) || timers.has(n.id)) continue
activeIds.add(n.id)
timers.set(
n.id,
setTimeout(() => {
timers.delete(n.id)
setExitingIds((prev) => new Set(prev).add(n.id))
setTimeout(() => {
if (isPausedRef.current) return
removeNotification(n.id)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(n.id)
return next
})
}, EXIT_ANIMATION_MS)
}, AUTO_DISMISS_MS)
)
}
for (const [id, timer] of timers) {
if (!activeIds.has(id) && !visibleNotifications.some((n) => n.id === id)) {
clearTimeout(timer)
timers.delete(id)
}
}
}, [visibleNotifications, removeNotification, isPaused])
useEffect(() => {
const timers = timersRef.current
return () => {
for (const timer of timers.values()) clearTimeout(timer)
}
}, [])
if (visibleNotifications.length === 0) {
return null
}
return (
<div ref={preventZoomRef} className='absolute right-[16px] bottom-[16px] z-30 grid'>
{[...visibleNotifications].reverse().map((notification, index, stacked) => {
const depth = stacked.length - index - 1
const xOffset = depth * STACK_OFFSET_PX
const hasAction = Boolean(notification.action)
const showCountdown = !isPaused && isAutoDismissable(notification)
return (
<div
key={notification.id}
style={
{
'--stack-offset': `${xOffset}px`,
animation: exitingIds.has(notification.id)
? `notification-exit ${EXIT_ANIMATION_MS}ms ease-in forwards`
: 'notification-enter 200ms ease-out forwards',
gridArea: '1 / 1',
} as React.CSSProperties
}
className='w-[240px] self-end overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)] shadow-sm'
>
<div className='flex flex-col gap-[8px] p-[8px]'>
<div className='flex items-start gap-[8px]'>
<div className='line-clamp-2 min-w-0 flex-1 font-medium text-[12px] text-[var(--text-body)]'>
{notification.level === 'error' && (
<span className='mr-[8px] mb-[2px] inline-block h-[8px] w-[8px] rounded-[2px] bg-[var(--text-error)] align-middle' />
)}
{notification.message}
</div>
<div className='flex shrink-0 items-start gap-[2px]'>
{showCountdown && <CountdownRing onPause={pauseAll} />}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => removeNotification(notification.id)}
aria-label='Dismiss notification'
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
>
<X className='h-[14px] w-[14px] text-[var(--text-icon)]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Shortcut keys='⌘E'>Clear all</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{hasAction && (
<Button
variant='active'
onClick={() => executeAction(notification.id, notification.action!)}
className='w-full rounded-[5px] px-[8px] py-[4px] font-medium text-[12px]'
>
{ACTION_LABELS[notification.action!.type] ?? 'Take action'}
</Button>
)}
</div>
</div>
)
})}
</div>
)
})

View File

@@ -65,7 +65,7 @@ export function CheckpointConfirmation({
{!isRestoreVariant && onContinue && (
<Button
onClick={onContinue}
variant='tertiary'
variant='primary'
size='sm'
className='flex-1'
disabled={isProcessing}

View File

@@ -1390,7 +1390,7 @@ function RunSkipButtons({
// Standardized buttons for all interrupt tools: Allow, Always Allow, Skip
return (
<div className='mt-[10px] flex gap-[6px]'>
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
<Button onClick={onRun} disabled={isProcessing} variant='primary'>
{isProcessing ? 'Allowing...' : 'Allow'}
</Button>
{showAlwaysAllow && (
@@ -2130,7 +2130,7 @@ export function ToolCall({
onStateChange?.('background')
await sendToolDecision(toolCall.id, 'background')
}}
variant='tertiary'
variant='primary'
title='Move to Background'
>
Move to Background
@@ -2144,7 +2144,7 @@ export function ToolCall({
onStateChange?.('background')
await sendToolDecision(toolCall.id, 'background')
}}
variant='tertiary'
variant='primary'
title='Wake'
>
Wake

View File

@@ -48,7 +48,8 @@ import { ChatDeploy, type ExistingChat } from './components/chat/chat'
import { ApiInfoModal } from './components/general/components/api-info-modal'
import { GeneralDeploy } from './components/general/general'
import { McpDeploy } from './components/mcp/mcp'
import { TemplateDeploy } from './components/template/template'
// import { TemplateDeploy } from './components/template/template'
const logger = createLogger('DeployModal')
@@ -497,9 +498,9 @@ export function DeployModal({
<ModalTabsTrigger value='chat'>Chat</ModalTabsTrigger>
)}
{/* <ModalTabsTrigger value='form'>Form</ModalTabsTrigger> */}
{!permissionConfig.hideDeployTemplate && (
{/* {!permissionConfig.hideDeployTemplate && (
<ModalTabsTrigger value='template'>Template</ModalTabsTrigger>
)}
)} */}
</ModalTabsList>
<ModalBody className='min-h-0 flex-1'>
@@ -563,7 +564,7 @@ export function DeployModal({
/>
</ModalTabsContent>
<ModalTabsContent value='template'>
{/* <ModalTabsContent value='template'>
{workflowId && (
<TemplateDeploy
workflowId={workflowId}
@@ -572,7 +573,7 @@ export function DeployModal({
onSubmittingChange={setTemplateSubmitting}
/>
)}
</ModalTabsContent>
</ModalTabsContent> */}
{/* <ModalTabsContent value='form'>
{workflowId && (
@@ -705,7 +706,7 @@ export function DeployModal({
</div>
</ModalFooter>
)}
{activeTab === 'template' && (
{/* {activeTab === 'template' && (
<ModalFooter className='items-center justify-between'>
{hasExistingTemplate && templateStatus ? (
<TemplateStatusBadge
@@ -743,7 +744,7 @@ export function DeployModal({
</Button>
</div>
</ModalFooter>
)}
)} */}
{/* {activeTab === 'form' && (
<ModalFooter className='items-center justify-between'>
<div />
@@ -1006,12 +1007,7 @@ function GeneralFooter({
<ModalFooter className='items-center justify-between'>
<StatusBadge isWarning={needsRedeployment} />
<div className='flex items-center gap-2'>
<Button
variant='default'
onClick={onUndeploy}
disabled={isUndeploying || isSubmitting}
className='px-[7px] py-[5px]'
>
<Button variant='default' onClick={onUndeploy} disabled={isUndeploying || isSubmitting}>
{isUndeploying ? 'Undeploying...' : 'Undeploy'}
</Button>
{needsRedeployment && (

View File

@@ -180,7 +180,7 @@ export function OAuthRequiredModal({
<Button variant='default' onClick={onClose}>
Cancel
</Button>
<Button variant='tertiary' type='button' onClick={handleConnectDirectly}>
<Button variant='primary' type='button' onClick={handleConnectDirectly}>
Connect
</Button>
</ModalFooter>

View File

@@ -863,7 +863,7 @@ try {
placeholder='Generate...'
/>
<Button
variant='tertiary'
variant='primary'
disabled={!schemaPromptInput.trim() || schemaGeneration.isStreaming}
onMouseDown={(e) => {
e.preventDefault()
@@ -955,7 +955,7 @@ try {
placeholder='Generate...'
/>
<Button
variant='tertiary'
variant='primary'
disabled={!codePromptInput.trim() || codeGeneration.isStreaming}
onMouseDown={(e) => {
e.preventDefault()
@@ -1135,7 +1135,7 @@ try {
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={() => setActiveSection('code')}
disabled={!isSchemaValid || !!schemaError}
>
@@ -1161,7 +1161,7 @@ try {
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={handleSave}
disabled={!isSchemaValid || !!schemaError || !hasChanges}
>

View File

@@ -129,7 +129,7 @@ export function ParameterWithLabel({
placeholder='Generate with AI...'
/>
<Button
variant='tertiary'
variant='primary'
disabled={!searchQuery.trim() || isStreaming}
onMouseDown={(e: React.MouseEvent) => {
e.preventDefault()

View File

@@ -331,7 +331,7 @@ const renderLabel = (
placeholder='Generate with AI...'
/>
<Button
variant='tertiary'
variant='primary'
disabled={!wandState.searchQuery.trim() || wandState.isStreaming}
onMouseDown={(e: React.MouseEvent) => {
e.preventDefault()

View File

@@ -27,14 +27,9 @@ import {
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { FilterPopover } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover'
import { OutputContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu'
import { StructuredOutput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output'
import { ToggleButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button'
import type {
BlockInfo,
TerminalFilters,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import type { ConsoleEntry } from '@/stores/terminal'
@@ -100,16 +95,11 @@ export interface OutputPanelProps {
handleCopy: () => void
filteredEntries: ConsoleEntry[]
handleExportConsole: (e: React.MouseEvent) => void
hasActiveFilters: boolean
handleClearConsole: (e: React.MouseEvent) => void
shouldShowCodeDisplay: boolean
outputDataStringified: string
outputData: unknown
handleClearConsoleFromMenu: () => void
filters: TerminalFilters
toggleBlock: (blockId: string) => void
toggleStatus: (status: 'error' | 'info') => void
uniqueBlocks: BlockInfo[]
}
/**
@@ -133,16 +123,11 @@ export const OutputPanel = React.memo(function OutputPanel({
handleCopy,
filteredEntries,
handleExportConsole,
hasActiveFilters,
handleClearConsole,
shouldShowCodeDisplay,
outputDataStringified,
outputData,
handleClearConsoleFromMenu,
filters,
toggleBlock,
toggleStatus,
uniqueBlocks,
}: OutputPanelProps) {
// Access store-backed settings directly to reduce prop drilling
const outputPanelWidth = useTerminalStore((state) => state.outputPanelWidth)
@@ -154,7 +139,6 @@ export const OutputPanel = React.memo(function OutputPanel({
const setStructuredView = useTerminalStore((state) => state.setStructuredView)
const outputContentRef = useRef<HTMLDivElement>(null)
const [filtersOpen, setFiltersOpen] = useState(false)
const [outputOptionsOpen, setOutputOptionsOpen] = useState(false)
const {
isSearchActive: isOutputSearchActive,
@@ -339,19 +323,6 @@ export const OutputPanel = React.memo(function OutputPanel({
)}
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
{/* Unified filter popover */}
{filteredEntries.length > 0 && (
<FilterPopover
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
toggleStatus={toggleStatus}
toggleBlock={toggleBlock}
uniqueBlocks={uniqueBlocks}
hasActiveFilters={hasActiveFilters}
/>
)}
{isOutputSearchActive ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>

View File

@@ -28,7 +28,6 @@ import { formatDuration } from '@/lib/core/utils/formatting'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
FilterPopover,
LogRowContextMenu,
OutputPanel,
StatusDisplay,
@@ -603,7 +602,6 @@ export const Terminal = memo(function Terminal() {
const [showCopySuccess, setShowCopySuccess] = useState(false)
const [showInput, setShowInput] = useState(false)
const [autoSelectEnabled, setAutoSelectEnabled] = useState(true)
const [filtersOpen, setFiltersOpen] = useState(false)
const [mainOptionsOpen, setMainOptionsOpen] = useState(false)
const [isTrainingEnvEnabled, setIsTrainingEnvEnabled] = useState(false)
@@ -676,23 +674,6 @@ export const Terminal = memo(function Terminal() {
return result
}, [executionGroups])
/**
* Get unique blocks (by ID) from all workflow entries
*/
const uniqueBlocks = useMemo(() => {
const blocksMap = new Map<string, { blockId: string; blockName: string; blockType: string }>()
allWorkflowEntries.forEach((entry) => {
if (!blocksMap.has(entry.blockId)) {
blocksMap.set(entry.blockId, {
blockId: entry.blockId,
blockName: entry.blockName,
blockType: entry.blockType,
})
}
})
return Array.from(blocksMap.values()).sort((a, b) => a.blockName.localeCompare(b.blockName))
}, [allWorkflowEntries])
/**
* Check if input data exists for selected entry
*/
@@ -1289,22 +1270,9 @@ export const Terminal = memo(function Terminal() {
{/* Left side - Logs label */}
<span className={TERMINAL_CONFIG.HEADER_TEXT_CLASS}>Logs</span>
{/* Right side - Filters and icons */}
{/* Right side - Icons and options */}
{!selectedEntry && (
<div className='flex items-center gap-[8px]'>
{/* Unified filter popover */}
{allWorkflowEntries.length > 0 && (
<FilterPopover
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
toggleStatus={toggleStatus}
toggleBlock={toggleBlock}
uniqueBlocks={uniqueBlocks}
hasActiveFilters={hasActiveFilters}
/>
)}
{/* Sort toggle */}
{allWorkflowEntries.length > 0 && (
<Tooltip.Root>
@@ -1497,16 +1465,11 @@ export const Terminal = memo(function Terminal() {
handleCopy={handleCopy}
filteredEntries={filteredEntries}
handleExportConsole={handleExportConsole}
hasActiveFilters={hasActiveFilters}
handleClearConsole={handleClearConsole}
shouldShowCodeDisplay={shouldShowCodeDisplay}
outputDataStringified={outputDataStringified}
outputData={outputData}
handleClearConsoleFromMenu={handleClearConsoleFromMenu}
filters={filters}
toggleBlock={toggleBlock}
toggleStatus={toggleStatus}
uniqueBlocks={uniqueBlocks}
/>
)}
</div>

View File

@@ -372,7 +372,7 @@ export function TrainingModal() {
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={() => {
useCopilotTrainingStore.getState().stopTraining()
setLocalPrompt('')
@@ -439,7 +439,7 @@ export function TrainingModal() {
<Button
onClick={handleStart}
disabled={!localTitle.trim() || !localPrompt.trim()}
variant='tertiary'
variant='primary'
className='w-full'
>
Start Training Session
@@ -470,7 +470,7 @@ export function TrainingModal() {
<div className='flex gap-[8px]'>
{selectedDatasets.size > 0 && (
<Button
variant='tertiary'
variant='primary'
onClick={handleSendSelected}
disabled={sendingSelected}
>
@@ -755,7 +755,7 @@ export function TrainingModal() {
sendingLiveWorkflow ||
currentWorkflow.getBlockCount() === 0
}
variant='tertiary'
variant='primary'
className={cn(
'w-full',
liveWorkflowSent && '!bg-green-600 !text-white hover:!bg-green-700',

View File

@@ -539,7 +539,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
<Button variant='default' onClick={handleClose} type='button' disabled={isSubmitting}>
Cancel
</Button>
<Button type='submit' variant='tertiary' disabled={isSubmitting || isProcessing}>
<Button type='submit' variant='primary' disabled={isSubmitting || isProcessing}>
{isSubmitting
? 'Submitting...'
: submitStatus === 'error'

View File

@@ -209,7 +209,7 @@ function ColorPickerSubmenu({
className='h-[20px] min-w-0 flex-1 rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] font-medium text-[11px] text-[var(--text-primary)] uppercase transition-colors focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<Button
variant='tertiary'
variant='primary'
disabled={!canSubmitHex}
onClick={(e) => {
e.stopPropagation()

View File

@@ -77,7 +77,7 @@ export function CreateWorkspaceModal({
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={() => void handleSubmit()}
disabled={!name.trim() || isCreating}
>

View File

@@ -575,7 +575,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
</Button>
<Button
type='button'
variant='tertiary'
variant='primary'
disabled={isSaving || isSubmitting}
onClick={handleSaveChanges}
tabIndex={hasPendingChanges && userPerms.canAdmin ? 0 : -1}
@@ -586,7 +586,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
<Button
type='button'
variant='tertiary'
variant='primary'
onClick={() => formRef.current?.requestSubmit()}
disabled={
!userPerms.canAdmin || isSubmitting || isSaving || !workspaceId || !hasNewInvites

View File

@@ -365,6 +365,7 @@ export function WorkspaceHeader({
align='start'
side={isCollapsed ? 'right' : 'bottom'}
sideOffset={isCollapsed ? 16 : 8}
className='flex max-h-none flex-col overflow-hidden'
style={
isCollapsed
? {
@@ -385,7 +386,7 @@ export function WorkspaceHeader({
</div>
) : (
<>
<div className='flex items-center gap-[8px] px-[2px] py-[4px]'>
<div className='flex items-center gap-[8px] px-[2px] py-[2px]'>
<div
className='flex h-[32px] w-[32px] flex-shrink-0 items-center justify-center rounded-[6px] font-medium text-[12px] text-white'
style={{
@@ -404,7 +405,7 @@ export function WorkspaceHeader({
</div>
</div>
<DropdownMenuGroup className='mt-[4px]'>
<DropdownMenuGroup className='mt-[4px] min-h-0 flex-1'>
<div className='flex max-h-[130px] flex-col gap-[2px] overflow-y-auto'>
{workspaces.map((workspace) => (
<div key={workspace.id}>
@@ -492,7 +493,9 @@ export function WorkspaceHeader({
</div>
))}
</div>
</DropdownMenuGroup>
<div className='flex flex-col gap-[2px]'>
<button
type='button'
className='flex w-full cursor-pointer select-none items-center gap-[8px] rounded-[5px] px-[8px] py-[5px] font-medium text-[12px] text-[var(--text-body)] outline-none transition-colors hover:bg-[var(--surface-active)] disabled:pointer-events-none disabled:opacity-50'
@@ -506,7 +509,7 @@ export function WorkspaceHeader({
<Plus className='h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
Create new workspace
</button>
</DropdownMenuGroup>
</div>
{!isInvitationsDisabled && (
<>

View File

@@ -1056,10 +1056,7 @@ export const Sidebar = memo(function Sidebar() {
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
<div className='flex items-center justify-between'>
<div
className={cn(
'font-base text-[var(--text-icon)] text-small',
isCollapsed && 'opacity-0'
)}
className={`font-base text-[var(--text-icon)] text-small${isCollapsed ? ' opacity-0' : ''}`}
>
All tasks
</div>

View File

@@ -18,5 +18,9 @@ export default function WorkspaceRootLayout({ children }: WorkspaceRootLayoutPro
}
: undefined
return <SocketProvider user={user}>{children}</SocketProvider>
return (
<SocketProvider user={user}>
<div className='tracking-[0.02em]'>{children}</div>
</SocketProvider>
)
}

View File

@@ -20,7 +20,7 @@ export const colors = {
/** Brand primary - purple */
brandPrimary: '#6f3dfa',
/** Brand tertiary - green (matches Run/Deploy buttons) */
brandTertiary: '#32bd7e',
brandTertiary: '#33C482',
/** Border/divider color */
divider: '#ededed',
/** Footer background */

View File

@@ -104,7 +104,7 @@ const buttonGroupItemVariants = cva(
{
variants: {
active: {
true: 'bg-[var(--brand-tertiary-2)] text-[var(--text-inverse)] border-[var(--brand-tertiary-2)] hover:brightness-106',
true: 'bg-[#1D1D1D] text-[var(--text-inverse)] border-[#1D1D1D] hover:bg-[#2A2A2A] hover:border-[#2A2A2A] dark:bg-white dark:border-white dark:hover:bg-[#E0E0E0] dark:hover:border-[#E0E0E0]',
false:
'bg-[var(--surface-4)] text-[var(--text-secondary)] border-[var(--border)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-6)] hover:border-[var(--border-1)]',
},

View File

@@ -14,11 +14,12 @@ const buttonVariants = cva(
'3d': 'text-[var(--text-tertiary)] border-t border-l border-r border-[var(--border-1)] shadow-[0_2px_0_0_var(--border-1)] hover:shadow-[0_4px_0_0_var(--border-1)] transition-all hover:-translate-y-0.5 hover:text-[var(--text-primary)]',
outline:
'border border-[var(--text-muted)] bg-transparent hover:border-[var(--text-secondary)]',
primary: 'bg-[var(--brand-400)] text-[var(--text-primary)] hover:brightness-106',
primary:
'bg-[#1D1D1D] text-[var(--text-inverse)] hover:text-[var(--text-inverse)] hover:bg-[#2A2A2A] dark:bg-white dark:hover:bg-[#E0E0E0]',
destructive: 'bg-[var(--text-error)] text-white hover:text-white hover:brightness-106',
secondary: 'bg-[var(--brand-secondary)] text-[var(--text-primary)]',
tertiary:
'!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:brightness-106 hover:!text-[var(--text-inverse)] ![transition-property:background-color,border-color,fill,stroke]',
'!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!text-[var(--text-inverse)] hover:!bg-[#2DAC72] dark:!bg-[var(--brand-tertiary-2)] dark:hover:!bg-[#2DAC72] dark:!text-[var(--text-inverse)] dark:hover:!text-[var(--text-inverse)]',
ghost: '',
subtle: 'text-[var(--text-body)] hover:text-[var(--text-body)] hover:bg-[var(--surface-4)]',
'ghost-secondary': 'text-[var(--text-muted)]',

View File

@@ -197,7 +197,7 @@ const ModalHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDi
({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn('flex min-w-0 items-center justify-between gap-2 px-4 py-4', className)}
className={cn('flex min-w-0 items-center justify-between gap-2 px-4 pt-4 pb-2', className)}
{...props}
>
<DialogPrimitive.Title className='min-w-0 font-medium text-[var(--text-primary)] text-base leading-none'>
@@ -296,7 +296,7 @@ const ModalTabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
'relative flex gap-[16px] px-4 pt-3',
'relative flex gap-[16px] px-4 pt-1',
disabled && 'pointer-events-none opacity-50',
className
)}
@@ -356,7 +356,7 @@ ModalTabsContent.displayName = 'ModalTabsContent'
*/
const ModalBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex-1 overflow-y-auto px-4 pb-4', className)} {...props} />
<div ref={ref} className={cn('flex-1 overflow-y-auto px-4 pt-3 pb-4', className)} {...props} />
)
)

View File

@@ -2,8 +2,6 @@
import {
createContext,
memo,
type ReactElement,
type ReactNode,
useCallback,
useContext,
@@ -14,30 +12,11 @@ import {
} from 'react'
import { X } from 'lucide-react'
import { createPortal } from 'react-dom'
import { Button } from '@/components/emcn/components/button/button'
import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
import { cn } from '@/lib/core/utils/cn'
const AUTO_DISMISS_MS = 10_000
const AUTO_DISMISS_MS = 0
const EXIT_ANIMATION_MS = 200
const MAX_VISIBLE = 4
const STACK_OFFSET_PX = 3
const RING_RADIUS = 5.5
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
const TOAST_KEYFRAMES = `
@keyframes toast-enter {
from { opacity: 0; transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97); }
to { opacity: 1; transform: translateX(var(--stack-offset, 0px)) scale(1); }
}
@keyframes toast-exit {
from { opacity: 1; transform: translateX(var(--stack-offset, 0px)) scale(1); }
to { opacity: 0; transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97); }
}
@keyframes toast-countdown {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: ${RING_CIRCUMFERENCE.toFixed(2)}; }
}`
const MAX_VISIBLE = 20
type ToastVariant = 'default' | 'success' | 'error'
@@ -49,17 +28,16 @@ interface ToastAction {
interface ToastData {
id: string
message: string
description?: string
variant: ToastVariant
icon?: ReactElement
action?: ToastAction
duration: number
createdAt: number
}
type ToastInput = {
message: string
description?: string
variant?: ToastVariant
icon?: ReactElement
action?: ToastAction
duration?: number
}
@@ -73,7 +51,6 @@ type ToastFn = {
interface ToastContextValue {
toast: ToastFn
dismiss: (id: string) => void
dismissAll: () => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
@@ -113,133 +90,81 @@ export function useToast() {
return ctx
}
function CountdownRing({ durationMs, onPause }: { durationMs: number; onPause: () => void }) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onPause}
aria-label='Keep visible'
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
>
<svg
width='14'
height='14'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
>
<circle cx='8' cy='8' r={RING_RADIUS} stroke='var(--border)' strokeWidth='1.5' />
<circle
cx='8'
cy='8'
r={RING_RADIUS}
stroke='var(--text-icon)'
strokeWidth='1.5'
strokeLinecap='round'
strokeDasharray={RING_CIRCUMFERENCE}
style={{
animation: `toast-countdown ${durationMs}ms linear forwards`,
}}
/>
</svg>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Keep visible</p>
</Tooltip.Content>
</Tooltip.Root>
)
const VARIANT_STYLES: Record<ToastVariant, string> = {
default: 'border-[var(--border)] bg-[var(--bg)] text-[var(--text-primary)]',
success:
'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/40 dark:bg-emerald-950/30 dark:text-emerald-200',
error:
'border-red-200 bg-red-50 text-red-900 dark:border-red-800/40 dark:bg-red-950/30 dark:text-red-200',
}
const ToastItem = memo(function ToastItem({
data,
depth,
isExiting,
showCountdown,
onDismiss,
onPauseCountdown,
onAction,
}: {
data: ToastData
depth: number
isExiting: boolean
showCountdown: boolean
onDismiss: (id: string) => void
onPauseCountdown: () => void
onAction: (id: string) => void
}) {
const xOffset = depth * STACK_OFFSET_PX
function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id: string) => void }) {
const [exiting, setExiting] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const dismiss = useCallback(() => {
setExiting(true)
setTimeout(() => onDismiss(t.id), EXIT_ANIMATION_MS)
}, [onDismiss, t.id])
useEffect(() => {
if (t.duration > 0) {
timerRef.current = setTimeout(dismiss, t.duration)
return () => clearTimeout(timerRef.current)
}
}, [dismiss, t.duration])
return (
<div
style={
{
'--stack-offset': `${xOffset}px`,
animation: isExiting
? `toast-exit ${EXIT_ANIMATION_MS}ms ease-in forwards`
: 'toast-enter 200ms ease-out forwards',
gridArea: '1 / 1',
} as React.CSSProperties
}
className='w-[240px] self-end overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)] shadow-sm'
className={cn(
'pointer-events-auto flex w-[320px] items-start gap-[8px] rounded-[8px] border px-[12px] py-[10px] shadow-md transition-all',
VARIANT_STYLES[t.variant],
exiting
? 'animate-[toast-exit_200ms_ease-in_forwards]'
: 'animate-[toast-enter_200ms_ease-out_forwards]'
)}
>
<div className='flex flex-col gap-[8px] p-[8px]'>
<div className='flex items-start gap-[8px]'>
{data.icon && (
<span className='flex h-[16px] shrink-0 items-center text-[var(--text-icon)]'>
{data.icon}
</span>
)}
<div className='line-clamp-2 min-w-0 flex-1 font-medium text-[12px] text-[var(--text-body)]'>
{data.variant === 'error' && (
<span className='mr-[8px] mb-[2px] inline-block h-[8px] w-[8px] rounded-[2px] bg-[var(--text-error)] align-middle' />
)}
{data.message}
</div>
<div className='flex shrink-0 items-start gap-[2px]'>
{showCountdown && (
<CountdownRing durationMs={data.duration} onPause={onPauseCountdown} />
)}
<Button
variant='ghost'
onClick={() => onDismiss(data.id)}
aria-label='Dismiss'
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
>
<X className='h-[14px] w-[14px] text-[var(--text-icon)]' />
</Button>
</div>
</div>
{data.action && (
<Button
variant='active'
onClick={() => onAction(data.id)}
className='w-full rounded-[5px] px-[8px] py-[4px] font-medium text-[12px]'
>
{data.action.label}
</Button>
<div className='min-w-0 flex-1'>
<p className='font-medium text-[13px] leading-[18px]'>{t.message}</p>
{t.description && (
<p className='mt-[2px] text-[12px] leading-[16px] opacity-80'>{t.description}</p>
)}
</div>
{t.action && (
<button
type='button'
onClick={() => {
t.action!.onClick()
dismiss()
}}
className='shrink-0 font-medium text-[13px] underline underline-offset-2 opacity-90 hover:opacity-100'
>
{t.action.label}
</button>
)}
<button
type='button'
onClick={dismiss}
className='shrink-0 rounded-[4px] p-[2px] opacity-60 hover:opacity-100'
>
<X className='h-[14px] w-[14px]' />
</button>
</div>
)
})
}
/**
* Toast container that renders toasts via portal.
* Mount once where you want toasts to appear. Renders stacked cards in the bottom-right.
* Mount once in your root layout.
*
* Visual design matches the workflow notification component: 240px cards, stacked with
* offset, countdown ring on auto-dismissing items, enter/exit animations.
* @example
* ```tsx
* <ToastProvider />
* ```
*/
export function ToastProvider({ children }: { children?: ReactNode }) {
const [toasts, setToasts] = useState<ToastData[]>([])
const [mounted, setMounted] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
useEffect(() => {
setMounted(true)
@@ -250,87 +175,17 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
const data: ToastData = {
id,
message: input.message,
description: input.description,
variant: input.variant ?? 'default',
icon: input.icon,
action: input.action,
duration: input.duration ?? AUTO_DISMISS_MS,
createdAt: Date.now(),
}
setToasts((prev) => [data, ...prev].slice(0, MAX_VISIBLE))
setToasts((prev) => [...prev, data].slice(-MAX_VISIBLE))
return id
}, [])
const dismissToast = useCallback((id: string) => {
setExitingIds((prev) => new Set(prev).add(id))
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}, EXIT_ANIMATION_MS)
}, [])
const dismissAll = useCallback(() => {
setToasts([])
setExitingIds(new Set())
for (const timer of timersRef.current.values()) clearTimeout(timer)
timersRef.current.clear()
}, [])
const pauseAll = useCallback(() => {
setIsPaused(true)
setExitingIds(new Set())
for (const timer of timersRef.current.values()) clearTimeout(timer)
timersRef.current.clear()
}, [])
const handleAction = useCallback(
(id: string) => {
const t = toasts.find((toast) => toast.id === id)
if (t?.action) {
t.action.onClick()
dismissToast(id)
}
},
[toasts, dismissToast]
)
useEffect(() => {
if (toasts.length === 0) {
if (isPaused) setIsPaused(false)
return
}
if (isPaused) return
const timers = timersRef.current
for (const t of toasts) {
if (t.duration <= 0 || timers.has(t.id)) continue
timers.set(
t.id,
setTimeout(() => {
timers.delete(t.id)
dismissToast(t.id)
}, t.duration)
)
}
for (const [id, timer] of timers) {
if (!toasts.some((t) => t.id === id)) {
clearTimeout(timer)
timers.delete(id)
}
}
}, [toasts, isPaused, dismissToast])
useEffect(() => {
const timers = timersRef.current
return () => {
for (const timer of timers.values()) clearTimeout(timer)
}
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const toastFn = useRef<ToastFn>(createToastFn(addToast))
@@ -344,44 +199,24 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
}, [addToast])
const ctx = useMemo<ToastContextValue>(
() => ({ toast: toastFn.current, dismiss: dismissToast, dismissAll }),
[dismissToast, dismissAll]
() => ({ toast: toastFn.current, dismiss: dismissToast }),
[dismissToast]
)
const visibleToasts = toasts.slice(0, MAX_VISIBLE)
return (
<ToastContext.Provider value={ctx}>
{children}
{mounted &&
visibleToasts.length > 0 &&
createPortal(
<>
<style>{TOAST_KEYFRAMES}</style>
<div
aria-live='polite'
aria-label='Toasts'
className='fixed right-[16px] bottom-[16px] z-[10000400] grid'
>
{[...visibleToasts].reverse().map((t, index, stacked) => {
const depth = stacked.length - index - 1
const showCountdown = !isPaused && t.duration > 0
return (
<ToastItem
key={t.id}
data={t}
depth={depth}
isExiting={exitingIds.has(t.id)}
showCountdown={showCountdown}
onDismiss={dismissToast}
onPauseCountdown={pauseAll}
onAction={handleAction}
/>
)
})}
</div>
</>,
<div
aria-live='polite'
aria-label='Notifications'
className='pointer-events-none fixed right-[16px] bottom-[16px] z-[10000400] flex flex-col-reverse items-end gap-[8px]'
>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={dismissToast} />
))}
</div>,
document.body
)}
</ToastContext.Provider>

View File

@@ -85,17 +85,42 @@ export const airtableConnector: ConnectorConfig = {
},
configFields: [
{
id: 'baseSelector',
title: 'Base',
type: 'selector',
selectorKey: 'airtable.bases',
canonicalParamId: 'baseId',
mode: 'basic',
placeholder: 'Select a base',
required: true,
},
{
id: 'baseId',
title: 'Base ID',
type: 'short-input',
canonicalParamId: 'baseId',
mode: 'advanced',
placeholder: 'e.g. appXXXXXXXXXXXXXX',
required: true,
},
{
id: 'tableSelector',
title: 'Table',
type: 'selector',
selectorKey: 'airtable.tables',
canonicalParamId: 'tableIdOrName',
mode: 'basic',
dependsOn: ['baseSelector'],
placeholder: 'Select a table',
required: true,
},
{
id: 'tableIdOrName',
title: 'Table Name or ID',
type: 'short-input',
canonicalParamId: 'tableIdOrName',
mode: 'advanced',
placeholder: 'e.g. Tasks or tblXXXXXXXXXXXXXX',
required: true,
},

View File

@@ -139,10 +139,22 @@ export const asanaConnector: ConnectorConfig = {
auth: { mode: 'oauth', provider: 'asana', requiredScopes: ['default'] },
configFields: [
{
id: 'workspaceSelector',
title: 'Workspace',
type: 'selector',
selectorKey: 'asana.workspaces',
canonicalParamId: 'workspace',
mode: 'basic',
placeholder: 'Select a workspace',
required: true,
},
{
id: 'workspace',
title: 'Workspace GID',
type: 'short-input',
canonicalParamId: 'workspace',
mode: 'advanced',
placeholder: 'e.g. 1234567890',
required: true,
},

View File

@@ -124,10 +124,23 @@ export const confluenceConnector: ConnectorConfig = {
placeholder: 'yoursite.atlassian.net',
required: true,
},
{
id: 'spaceSelector',
title: 'Space',
type: 'selector',
selectorKey: 'confluence.spaces',
canonicalParamId: 'spaceKey',
mode: 'basic',
dependsOn: ['domain'],
placeholder: 'Select a space',
required: true,
},
{
id: 'spaceKey',
title: 'Space Key',
type: 'short-input',
canonicalParamId: 'spaceKey',
mode: 'advanced',
placeholder: 'e.g. ENG, PRODUCT',
required: true,
},

View File

@@ -158,7 +158,11 @@ export const githubConnector: ConnectorConfig = {
version: '1.0.0',
icon: GithubIcon,
auth: { mode: 'oauth', provider: 'github', requiredScopes: ['repo'] },
auth: {
mode: 'apiKey',
label: 'Personal Access Token',
placeholder: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
},
configFields: [
{

View File

@@ -291,14 +291,27 @@ export const gmailConnector: ConnectorConfig = {
auth: {
mode: 'oauth',
provider: 'google-email',
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
requiredScopes: ['https://www.googleapis.com/auth/gmail.modify'],
},
configFields: [
{
id: 'labelSelector',
title: 'Label',
type: 'selector',
selectorKey: 'gmail.labels',
canonicalParamId: 'label',
mode: 'basic',
placeholder: 'Select a label',
required: false,
description: 'Only sync emails with this label. Leave empty for all mail.',
},
{
id: 'label',
title: 'Label',
type: 'short-input',
canonicalParamId: 'label',
mode: 'advanced',
placeholder: 'e.g. INBOX, IMPORTANT, or a custom label name',
required: false,
description: 'Only sync emails with this label. Leave empty for all mail.',

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