mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(misc): skills import, MCP modal, workmark, dispatch modals, collapsed tasks and workflows manipulation, README (#3777)
* feat: skills import, MCP modal updates, wordmark icon, tool-input improvements - Add skills import functionality (route + components + utils) - Update MCP deploy modal - Add Wordmark emcn icon + logo SVG assets - Improve tool-input component - Update README branding to new wordmark - Add ban-spam-accounts admin script * fix: resolve build error and audit findings from simplify review - Add BUILT_IN_TOOL_TYPES export to blocks/utils.ts (was removed from tool-input.tsx but never added to the new import target — caused build error "Export BUILT_IN_TOOL_TYPES doesn't exist in target module") - Export Wordmark from emcn icons barrel (index.ts) - Derive isDragging from dragCounter in skill-import.tsx instead of maintaining redundant state that could desync - Replace manual AbortController/setTimeout with AbortSignal.timeout() in skills import API route (Node 17.3+ supported, cleaner no-cleanup) - Use useId() for SVG gradient ID in wordmark.tsx to prevent duplicate ID collisions if rendered multiple times on the same page * fix(scripts): fix docs mismatch and N+1 query in ban-spam-accounts - Fix comment: default pattern is @vapu.xyz, not @sharebot.net - Replace per-user stats loop with a single aggregated JOIN query * feat: wire wordmark into sidebar, fix credential selector modal dispatch - Show Wordmark (icon + text) in the expanded sidebar instead of the bare Sim icon; collapsed state keeps the small Sim icon unchanged - Untrack scripts/ban-spam-accounts.ts (gitignored; one-off script) - Credential selector: open OAuthRequiredModal inline instead of navigating to Settings → Integrations (matches MCP/tool-input pattern) - Credential selector: update billing import from getSubscriptionAccessState to getSubscriptionStatus; drop writePendingCredentialCreateRequest and useSettingsNavigation dependencies * feat(misc): misc UX/UI improvements * more random fixes * more random fixes * fix: address PR review findings from cursor bugbot - settings-sidebar: use getSubscriptionAccessState instead of getSubscriptionStatus so billingBlocked and status validity are checked; add requiresMax gating so max-plan-only nav items (inbox) are hidden for lower-tier users - credential-selector: same getSubscriptionAccessState migration for credential sets visibility check - mothership chats PATCH: change else if to if for isUnread so both title and isUnread can be updated in a single request - skills import: check Content-Length header before reading response body to avoid loading oversized files into memory * fix(skills): add ZIP file size guard before extraction Checks file.size > 5 MB before calling extractSkillFromZip to prevent zip bombs from exhausting browser memory at the client-side upload path. * feat(settings-sidebar): show locked upsell items with plan badge Sim Mailer (requiresMax) and Email Polling (requiresTeam) now always appear in the settings sidebar when billing is enabled and the deployment is hosted. If the user lacks the required plan they see a small MAX / TEAM badge next to the label and are taken to the page which already contains the upgrade prompt. Enterprise (Access Control, SSO) and Team management stay hard-hidden for lower tiers. Admin/superuser items stay truly hidden. * fix(settings-sidebar): remove flex-1 from label span to fix text centering * feat(settings-sidebar): remove team gate from email polling, keep only mailer max gate * feat(subscription): billing details layout and Enterprise card improvements - Move Enterprise plan card into the plan grid (auto-fit columns) instead of a separate standalone section below billing details - Refactor billing details section: remove outer border/background, separate each row with top border + padding for cleaner separation - Update button variants: Add Credits → active, Invoices → active * fix(mothership): prevent lastSeenAt conflict when both title and isUnread are patched together Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sidebar): prevent double-save race in flyout inline rename on Enter+blur Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(skills): normalize CRLF line endings before parsing SKILL.md frontmatter Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
7b96b0e8e8
commit
7583c8fbf4
@@ -1,16 +1,20 @@
|
||||
<p align="center">
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer">
|
||||
<img src="apps/sim/public/logo/reverse/text/large.png" alt="Sim Logo" width="500"/>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="apps/sim/public/logo/wordmark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="apps/sim/public/logo/wordmark-dark.svg">
|
||||
<img src="apps/sim/public/logo/wordmark-dark.svg" alt="Sim Logo" width="300"/>
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">The open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-33c482" alt="Sim.ai"></a>
|
||||
<a href="https://discord.gg/Hr4UWYEcTT" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simdotai?style=social" alt="Twitter"></a>
|
||||
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
|
||||
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-33c482.svg" alt="Documentation"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -42,7 +46,7 @@ Upload documents to a vector store and let agents answer questions grounded in y
|
||||
|
||||
### Cloud-hosted: [sim.ai](https://sim.ai)
|
||||
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjE2IiBoZWlnaHQ9IjYxNiIgdmlld0JveD0iMCAwIDYxNiA2MTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTU5XzMxMykiPgo8cGF0aCBkPSJNNjE2IDBIMFY2MTZINjE2VjBaIiBmaWxsPSIjNkYzREZBIi8+CjxwYXRoIGQ9Ik04MyAzNjUuNTY3SDExM0MxMTMgMzczLjgwNSAxMTYgMzgwLjM3MyAxMjIgMzg1LjI3MkMxMjggMzg5Ljk0OCAxMzYuMTExIDM5Mi4yODUgMTQ2LjMzMyAzOTIuMjg1QzE1Ny40NDQgMzkyLjI4NSAxNjYgMzkwLjE3MSAxNzIgMzg1LjkzOUMxNzcuOTk5IDM4MS40ODcgMTgxIDM3NS41ODYgMTgxIDM2OC4yMzlDMTgxIDM2Mi44OTUgMTc5LjMzMyAzNTguNDQyIDE3NiAzNTQuODhDMTcyLjg4OSAzNTEuMzE4IDE2Ny4xMTEgMzQ4LjQyMiAxNTguNjY3IDM0Ni4xOTZMMTMwIDMzOS41MTdDMTE1LjU1NSAzMzUuOTU1IDEwNC43NzggMzMwLjQ5OSA5Ny42NjY1IDMyMy4xNTFDOTAuNzc3NSAzMTUuODA0IDg3LjMzMzQgMzA2LjExOSA4Ny4zMzM0IDI5NC4wOTZDODcuMzMzNCAyODQuMDc2IDg5Ljg4OSAyNzUuMzkyIDk0Ljk5OTYgMjY4LjA0NUMxMDAuMzMzIDI2MC42OTcgMTA3LjU1NSAyNTUuMDIgMTE2LjY2NiAyNTEuMDEyQzEyNiAyNDcuMDA0IDEzNi42NjcgMjQ1IDE0OC42NjYgMjQ1QzE2MC42NjcgMjQ1IDE3MSAyNDcuMTE2IDE3OS42NjcgMjUxLjM0NkMxODguNTU1IDI1NS41NzYgMTk1LjQ0NCAyNjEuNDc3IDIwMC4zMzMgMjY5LjA0N0MyMDUuNDQ0IDI3Ni42MTcgMjA4LjExMSAyODUuNjM0IDIwOC4zMzMgMjk2LjA5OUgxNzguMzMzQzE3OC4xMTEgMjg3LjYzOCAxNzUuMzMzIDI4MS4wNyAxNjkuOTk5IDI3Ni4zOTRDMTY0LjY2NiAyNzEuNzE5IDE1Ny4yMjIgMjY5LjM4MSAxNDcuNjY3IDI2OS4zODFDMTM3Ljg4OSAyNjkuMzgxIDEzMC4zMzMgMjcxLjQ5NiAxMjUgMjc1LjcyNkMxMTkuNjY2IDI3OS45NTcgMTE3IDI4NS43NDYgMTE3IDI5My4wOTNDMTE3IDMwNC4wMDMgMTI1IDMxMS40NjIgMTQxIDMxNS40N0wxNjkuNjY3IDMyMi40ODNDMTgzLjQ0NSAzMjUuNiAxOTMuNzc4IDMzMC43MjIgMjAwLjY2NyAzMzcuODQ3QzIwNy41NTUgMzQ0Ljc0OSAyMTEgMzU0LjIxMiAyMTEgMzY2LjIzNUMyMTEgMzc2LjQ3NyAyMDguMjIyIDM4NS40OTQgMjAyLjY2NiAzOTMuMjg3QzE5Ny4xMTEgNDAwLjg1NyAxODkuNDQ0IDQwNi43NTggMTc5LjY2NyA0MTAuOTg5QzE3MC4xMTEgNDE0Ljk5NiAxNTguNzc4IDQxNyAxNDUuNjY3IDQxN0MxMjYuNTU1IDQxNyAxMTEuMzMzIDQxMi4zMjUgOTkuOTk5NyA0MDIuOTczQzg4LjY2NjggMzkzLjYyMSA4MyAzODEuMTUzIDgzIDM2NS41NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjMyLjI5MSA0MTNWMjUwLjA4MkMyNDQuNjg0IDI1NC42MTQgMjUwLjE0OCAyNTQuNjE0IDI2My4zNzEgMjUwLjA4MlY0MTNIMjMyLjI5MVpNMjQ3LjUgMjM5LjMxM0MyNDEuOTkgMjM5LjMxMyAyMzcuMTQgMjM3LjMxMyAyMzIuOTUyIDIzMy4zMTZDMjI4Ljk4NCAyMjkuMDk1IDIyNyAyMjQuMjA5IDIyNyAyMTguNjU2QzIyNyAyMTIuODgyIDIyOC45ODQgMjA3Ljk5NSAyMzIuOTUyIDIwMy45OTdDMjM3LjE0IDE5OS45OTkgMjQxLjk5IDE5OCAyNDcuNSAxOThDMjUzLjIzMSAxOTggMjU4LjA4IDE5OS45OTkgMjYyLjA0OSAyMDMuOTk3QzI2Ni4wMTYgMjA3Ljk5NSAyNjggMjEyLjg4MiAyNjggMjE4LjY1NkMyNjggMjI0LjIwOSAyNjYuMDE2IDIyOS4wOTUgMjYyLjA0OSAyMzMuMzE2QzI1OC4wOCAyMzcuMzEzIDI1My4yMzEgMjM5LjMxMyAyNDcuNSAyMzkuMzEzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMxOS4zMzMgNDEzSDI4OFYyNDkuNjc2SDMxNlYyNzcuMjMzQzMxOS4zMzMgMjY4LjEwNCAzMjUuNzc4IDI2MC4zNjQgMzM0LjY2NyAyNTQuMzUyQzM0My43NzggMjQ4LjExNyAzNTQuNzc4IDI0NSAzNjcuNjY3IDI0NUMzODIuMTExIDI0NSAzOTQuMTEyIDI0OC44OTcgNDAzLjY2NyAyNTYuNjlDNDEzLjIyMiAyNjQuNDg0IDQxOS40NDQgMjc0LjgzNyA0MjIuMzM0IDI4Ny43NTJINDE2LjY2N0M0MTguODg5IDI3NC44MzcgNDI1IDI2NC40ODQgNDM1IDI1Ni42OUM0NDUgMjQ4Ljg5NyA0NTcuMzM0IDI0NSA0NzIgMjQ1QzQ5MC42NjYgMjQ1IDUwNS4zMzQgMjUwLjQ1NSA1MTYgMjYxLjM2NkM1MjYuNjY3IDI3Mi4yNzYgNTMyIDI4Ny4xOTUgNTMyIDMwNi4xMjFWNDEzSDUwMS4zMzNWMzEzLjgwNEM1MDEuMzMzIDMwMC44ODkgNDk4IDI5MC45ODEgNDkxLjMzMyAyODQuMDc4QzQ4NC44ODkgMjc2Ljk1MiA0NzYuMTExIDI3My4zOSA0NjUgMjczLjM5QzQ1Ny4yMjIgMjczLjM5IDQ1MC4zMzMgMjc1LjE3MSA0NDQuMzM0IDI3OC43MzRDNDM4LjU1NiAyODIuMDc0IDQzNCAyODYuOTcyIDQzMC42NjcgMjkzLjQzQzQyNy4zMzMgMjk5Ljg4NyA0MjUuNjY3IDMwNy40NTcgNDI1LjY2NyAzMTYuMTQxVjQxM0gzOTQuNjY3VjMxMy40NjlDMzk0LjY2NyAzMDAuNTU1IDM5MS40NDUgMjkwLjc1OCAzODUgMjg0LjA3OEMzNzguNTU2IDI3Ny4xNzUgMzY5Ljc3OCAyNzMuNzI0IDM1OC42NjcgMjczLjcyNEMzNTAuODg5IDI3My43MjQgMzQ0IDI3NS41MDUgMzM4IDI3OS4wNjhDMzMyLjIyMiAyODIuNDA4IDMyNy42NjcgMjg3LjMwNyAzMjQuMzMzIDI5My43NjNDMzIxIDI5OS45OTggMzE5LjMzMyAzMDcuNDU3IDMxOS4zMzMgMzE2LjE0MVY0MTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzExNTlfMzEzIj4KPHJlY3Qgd2lkdGg9IjYxNiIgaGVpZ2h0PSI2MTYiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg==&logoColor=white" alt="Sim.ai"></a>
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-33c482?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjE2IiBoZWlnaHQ9IjYxNiIgdmlld0JveD0iMCAwIDYxNiA2MTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTU5XzMxMykiPgo8cGF0aCBkPSJNNjE2IDBIMFY2MTZINjE2VjBaIiBmaWxsPSIjMzNjNDgyIi8+CjxwYXRoIGQ9Ik04MyAzNjUuNTY3SDExM0MxMTMgMzczLjgwNSAxMTYgMzgwLjM3MyAxMjIgMzg1LjI3MkMxMjggMzg5Ljk0OCAxMzYuMTExIDM5Mi4yODUgMTQ2LjMzMyAzOTIuMjg1QzE1Ny40NDQgMzkyLjI4NSAxNjYgMzkwLjE3MSAxNzIgMzg1LjkzOUMxNzcuOTk5IDM4MS40ODcgMTgxIDM3NS41ODYgMTgxIDM2OC4yMzlDMTgxIDM2Mi44OTUgMTc5LjMzMyAzNTguNDQyIDE3NiAzNTQuODhDMTcyLjg4OSAzNTEuMzE4IDE2Ny4xMTEgMzQ4LjQyMiAxNTguNjY3IDM0Ni4xOTZMMTMwIDMzOS41MTdDMTE1LjU1NSAzMzUuOTU1IDEwNC43NzggMzMwLjQ5OSA5Ny42NjY1IDMyMy4xNTFDOTAuNzc3NSAzMTUuODA0IDg3LjMzMzQgMzA2LjExOSA4Ny4zMzM0IDI5NC4wOTZDODcuMzMzNCAyODQuMDc2IDg5Ljg4OSAyNzUuMzkyIDk0Ljk5OTYgMjY4LjA0NUMxMDAuMzMzIDI2MC42OTcgMTA3LjU1NSAyNTUuMDIgMTE2LjY2NiAyNTEuMDEyQzEyNiAyNDcuMDA0IDEzNi42NjcgMjQ1IDE0OC42NjYgMjQ1QzE2MC42NjcgMjQ1IDE3MSAyNDcuMTE2IDE3OS42NjcgMjUxLjM0NkMxODguNTU1IDI1NS41NzYgMTk1LjQ0NCAyNjEuNDc3IDIwMC4zMzMgMjY5LjA0N0MyMDUuNDQ0IDI3Ni42MTcgMjA4LjExMSAyODUuNjM0IDIwOC4zMzMgMjk2LjA5OUgxNzguMzMzQzE3OC4xMTEgMjg3LjYzOCAxNzUuMzMzIDI4MS4wNyAxNjkuOTk5IDI3Ni4zOTRDMTY0LjY2NiAyNzEuNzE5IDE1Ny4yMjIgMjY5LjM4MSAxNDcuNjY3IDI2OS4zODFDMTM3Ljg4OSAyNjkuMzgxIDEzMC4zMzMgMjcxLjQ5NiAxMjUgMjc1LjcyNkMxMTkuNjY2IDI3OS45NTcgMTE3IDI4NS43NDYgMTE3IDI5My4wOTNDMTE3IDMwNC4wMDMgMTI1IDMxMS40NjIgMTQxIDMxNS40N0wxNjkuNjY3IDMyMi40ODNDMTgzLjQ0NSAzMjUuNiAxOTMuNzc4IDMzMC43MjIgMjAwLjY2NyAzMzcuODQ3QzIwNy41NTUgMzQ0Ljc0OSAyMTEgMzU0LjIxMiAyMTEgMzY2LjIzNUMyMTEgMzc2LjQ3NyAyMDguMjIyIDM4NS40OTQgMjAyLjY2NiAzOTMuMjg3QzE5Ny4xMTEgNDAwLjg1NyAxODkuNDQ0IDQwNi43NTggMTc5LjY2NyA0MTAuOTg5QzE3MC4xMTEgNDE0Ljk5NiAxNTguNzc4IDQxNyAxNDUuNjY3IDQxN0MxMjYuNTU1IDQxNyAxMTEuMzMzIDQxMi4zMjUgOTkuOTk5NyA0MDIuOTczQzg4LjY2NjggMzkzLjYyMSA4MyAzODEuMTUzIDgzIDM2NS41NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjMyLjI5MSA0MTNWMjUwLjA4MkMyNDQuNjg0IDI1NC42MTQgMjUwLjE0OCAyNTQuNjE0IDI2My4zNzEgMjUwLjA4MlY0MTNIMjMyLjI5MVpNMjQ3LjUgMjM5LjMxM0MyNDEuOTkgMjM5LjMxMyAyMzcuMTQgMjM3LjMxMyAyMzIuOTUyIDIzMy4zMTZDMjI4Ljk4NCAyMjkuMDk1IDIyNyAyMjQuMjA5IDIyNyAyMTguNjU2QzIyNyAyMTIuODgyIDIyOC45ODQgMjA3Ljk5NSAyMzIuOTUyIDIwMy45OTdDMjM3LjE0IDE5OS45OTkgMjQxLjk5IDE5OCAyNDcuNSAxOThDMjUzLjIzMSAxOTggMjU4LjA4IDE5OS45OTkgMjYyLjA0OSAyMDMuOTk3QzI2Ni4wMTYgMjA3Ljk5NSAyNjggMjEyLjg4MiAyNjggMjE4LjY1NkMyNjggMjI0LjIwOSAyNjYuMDE2IDIyOS4wOTUgMjYyLjA0OSAyMzMuMzE2QzI1OC4wOCAyMzcuMzEzIDI1My4yMzEgMjM5LjMxMyAyNDcuNSAyMzkuMzEzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMxOS4zMzMgNDEzSDI4OFYyNDkuNjc2SDMxNlYyNzcuMjMzQzMxOS4zMzMgMjY4LjEwNCAzMjUuNzc4IDI2MC4zNjQgMzM0LjY2NyAyNTQuMzUyQzM0My43NzggMjQ4LjExNyAzNTQuNzc4IDI0NSAzNjcuNjY3IDI0NUMzODIuMTExIDI0NSAzOTQuMTEyIDI0OC44OTcgNDAzLjY2NyAyNTYuNjlDNDEzLjIyMiAyNjQuNDg0IDQxOS40NDQgMjc0LjgzNyA0MjIuMzM0IDI4Ny43NTJINDE2LjY2N0M0MTguODg5IDI3NC44MzcgNDI1IDI2NC40ODQgNDM1IDI1Ni42OUM0NDUgMjQ4Ljg5NyA0NTcuMzM0IDI0NSA0NzIgMjQ1QzQ5MC42NjYgMjQ1IDUwNS4zMzQgMjUwLjQ1NSA1MTYgMjYxLjM2NkM1MjYuNjY3IDI3Mi4yNzYgNTMyIDI4Ny4xOTUgNTMyIDMwNi4xMjFWNDEzSDUwMS4zMzNWMzEzLjgwNEM1MDEuMzMzIDMwMC44ODkgNDk4IDI5MC45ODEgNDkxLjMzMyAyODQuMDc4QzQ4NC44ODkgMjc2Ljk1MiA0NzYuMTExIDI3My4zOSA0NjUgMjczLjM5QzQ1Ny4yMjIgMjczLjM5IDQ1MC4zMzMgMjc1LjE3MSA0NDQuMzM0IDI3OC43MzRDNDM4LjU1NiAyODIuMDc0IDQzNCAyODYuOTcyIDQzMC42NjcgMjkzLjQzQzQyNy4zMzMgMjk5Ljg4NyA0MjUuNjY3IDMwNy40NTcgNDI1LjY2NyAzMTYuMTQxVjQxM0gzOTQuNjY3VjMxMy40NjlDMzk0LjY2NyAzMDAuNTU1IDM5MS40NDUgMjkwLjc1OCAzODUgMjg0LjA3OEMzNzguNTU2IDI3Ny4xNzUgMzY5Ljc3OCAyNzMuNzI0IDM1OC42NjcgMjczLjcyNEMzNTAuODg5IDI3My43MjQgMzQ0IDI3NS41MDUgMzM4IDI3OS4wNjhDMzMyLjIyMiAyODIuNDA4IDMyNy42NjcgMjg3LjMwNyAzMjQuMzMzIDI5My43NjNDMzIxIDI5OS45OTggMzE5LjMzMyAzMDcuNDU3IDMxOS4zMzMgMzE2LjE0MVY0MTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzExNTlfMzEzIj4KPHJlY3Qgd2lkdGg9IjYxNiIgaGVpZ2h0PSI2MTYiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+&logoColor=white" alt="Sim.ai"></a>
|
||||
|
||||
### Self-hosted: NPM Package
|
||||
|
||||
@@ -70,43 +74,7 @@ docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
#### Using Local Models with Ollama
|
||||
|
||||
Run Sim with local AI models using [Ollama](https://ollama.ai) - no external APIs required:
|
||||
|
||||
```bash
|
||||
# Start with GPU support (automatically downloads gemma3:4b model)
|
||||
docker compose -f docker-compose.ollama.yml --profile setup up -d
|
||||
|
||||
# For CPU-only systems:
|
||||
docker compose -f docker-compose.ollama.yml --profile cpu --profile setup up -d
|
||||
```
|
||||
|
||||
Wait for the model to download, then visit [http://localhost:3000](http://localhost:3000). Add more models with:
|
||||
```bash
|
||||
docker compose -f docker-compose.ollama.yml exec ollama ollama pull llama3.1:8b
|
||||
```
|
||||
|
||||
#### Using an External Ollama Instance
|
||||
|
||||
If Ollama is running on your host machine, use `host.docker.internal` instead of `localhost`:
|
||||
|
||||
```bash
|
||||
OLLAMA_URL=http://host.docker.internal:11434 docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
On Linux, use your host's IP address or add `extra_hosts: ["host.docker.internal:host-gateway"]` to the compose file.
|
||||
|
||||
#### Using vLLM
|
||||
|
||||
Sim supports [vLLM](https://docs.vllm.ai/) for self-hosted models. Set `VLLM_BASE_URL` and optionally `VLLM_API_KEY` in your environment.
|
||||
|
||||
### Self-hosted: Dev Containers
|
||||
|
||||
1. Open VS Code with the [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
|
||||
2. Open the project and click "Reopen in Container" when prompted
|
||||
3. Run `bun run dev:full` in the terminal or use the `sim-start` alias
|
||||
- This starts both the main application and the realtime socket server
|
||||
Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details.
|
||||
|
||||
### Self-hosted: Manual Setup
|
||||
|
||||
@@ -159,18 +127,7 @@ Copilot is a Sim-managed service. To use Copilot on a self-hosted instance:
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Key environment variables for self-hosted deployments. See [`.env.example`](apps/sim/.env.example) for defaults or [`env.ts`](apps/sim/lib/core/config/env.ts) for the full list.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `DATABASE_URL` | Yes | PostgreSQL connection string with pgvector |
|
||||
| `BETTER_AUTH_SECRET` | Yes | Auth secret (`openssl rand -hex 32`) |
|
||||
| `BETTER_AUTH_URL` | Yes | Your app URL (e.g., `http://localhost:3000`) |
|
||||
| `NEXT_PUBLIC_APP_URL` | Yes | Public app URL (same as above) |
|
||||
| `ENCRYPTION_KEY` | Yes | Encrypts environment variables (`openssl rand -hex 32`) |
|
||||
| `INTERNAL_API_SECRET` | Yes | Encrypts internal API routes (`openssl rand -hex 32`) |
|
||||
| `API_ENCRYPTION_KEY` | Yes | Encrypts API keys (`openssl rand -hex 32`) |
|
||||
| `COPILOT_API_KEY` | No | API key from sim.ai for Copilot features |
|
||||
See the [environment variables reference](https://docs.sim.ai/self-hosting/environment-variables) for the full list, or [`apps/sim/.env.example`](apps/sim/.env.example) for defaults.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
||||
@@ -54,11 +54,23 @@ html[data-sidebar-collapsed] .sidebar-container .text-small {
|
||||
transition: opacity 60ms ease;
|
||||
}
|
||||
|
||||
.sidebar-container .sidebar-collapse-show {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
.sidebar-container[data-collapsed] .sidebar-collapse-hide,
|
||||
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-container[data-collapsed] .sidebar-collapse-show,
|
||||
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { taskPubSub } from '@/lib/copilot/task-events'
|
||||
|
||||
const logger = createLogger('RenameChatAPI')
|
||||
|
||||
const RenameChatSchema = z.object({
|
||||
chatId: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
})
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { chatId, title } = RenameChatSchema.parse(body)
|
||||
|
||||
const chat = await getAccessibleCopilotChat(chatId, session.user.id)
|
||||
if (!chat) {
|
||||
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const [updated] = await db
|
||||
.update(copilotChats)
|
||||
.set({ title, updatedAt: now, lastSeenAt: now })
|
||||
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id)))
|
||||
.returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId })
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info('Chat renamed', { chatId, title })
|
||||
|
||||
if (updated.workspaceId) {
|
||||
taskPubSub?.publishStatusChanged({
|
||||
workspaceId: updated.workspaceId,
|
||||
chatId,
|
||||
type: 'renamed',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
logger.error('Error renaming chat:', error)
|
||||
return NextResponse.json({ success: false, error: 'Failed to rename chat' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { appendCopilotLogContext } from '@/lib/copilot/logging'
|
||||
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
createInternalServerErrorResponse,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import { taskPubSub } from '@/lib/copilot/task-events'
|
||||
|
||||
const logger = createLogger('MothershipChatAPI')
|
||||
|
||||
const UpdateChatSchema = z
|
||||
.object({
|
||||
title: z.string().trim().min(1).max(200).optional(),
|
||||
isUnread: z.boolean().optional(),
|
||||
})
|
||||
.refine((data) => data.title !== undefined || data.isUnread !== undefined, {
|
||||
message: 'At least one field must be provided',
|
||||
})
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ chatId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const { chatId } = await params
|
||||
if (!chatId) {
|
||||
return createBadRequestResponse('chatId is required')
|
||||
}
|
||||
|
||||
const chat = await getAccessibleCopilotChat(chatId, userId)
|
||||
if (!chat || chat.type !== 'mothership') {
|
||||
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let streamSnapshot: {
|
||||
events: Array<{ eventId: number; streamId: string; event: Record<string, unknown> }>
|
||||
status: string
|
||||
} | null = null
|
||||
|
||||
if (chat.conversationId) {
|
||||
try {
|
||||
const [meta, events] = await Promise.all([
|
||||
getStreamMeta(chat.conversationId),
|
||||
readStreamEvents(chat.conversationId, 0),
|
||||
])
|
||||
|
||||
streamSnapshot = {
|
||||
events: events || [],
|
||||
status: meta?.status || 'unknown',
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
appendCopilotLogContext('Failed to read stream snapshot for mothership chat', {
|
||||
messageId: chat.conversationId || undefined,
|
||||
}),
|
||||
{
|
||||
chatId,
|
||||
conversationId: chat.conversationId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
chat: {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
messages: Array.isArray(chat.messages) ? chat.messages : [],
|
||||
conversationId: chat.conversationId || null,
|
||||
resources: Array.isArray(chat.resources) ? chat.resources : [],
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
...(streamSnapshot ? { streamSnapshot } : {}),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching mothership chat:', error)
|
||||
return createInternalServerErrorResponse('Failed to fetch chat')
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ chatId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const { chatId } = await params
|
||||
if (!chatId) {
|
||||
return createBadRequestResponse('chatId is required')
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { title, isUnread } = UpdateChatSchema.parse(body)
|
||||
|
||||
const updates: Record<string, unknown> = {}
|
||||
|
||||
if (title !== undefined) {
|
||||
const now = new Date()
|
||||
updates.title = title
|
||||
updates.updatedAt = now
|
||||
if (isUnread === undefined) {
|
||||
updates.lastSeenAt = now
|
||||
}
|
||||
}
|
||||
if (isUnread !== undefined) {
|
||||
updates.lastSeenAt = isUnread ? null : sql`GREATEST(${copilotChats.updatedAt}, NOW())`
|
||||
}
|
||||
|
||||
const [updatedChat] = await db
|
||||
.update(copilotChats)
|
||||
.set(updates)
|
||||
.where(
|
||||
and(
|
||||
eq(copilotChats.id, chatId),
|
||||
eq(copilotChats.userId, userId),
|
||||
eq(copilotChats.type, 'mothership')
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
id: copilotChats.id,
|
||||
workspaceId: copilotChats.workspaceId,
|
||||
})
|
||||
|
||||
if (!updatedChat) {
|
||||
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (title !== undefined && updatedChat.workspaceId) {
|
||||
taskPubSub?.publishStatusChanged({
|
||||
workspaceId: updatedChat.workspaceId,
|
||||
chatId,
|
||||
type: 'renamed',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createBadRequestResponse('Invalid request data')
|
||||
}
|
||||
logger.error('Error updating mothership chat:', error)
|
||||
return createInternalServerErrorResponse('Failed to update chat')
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ chatId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const { chatId } = await params
|
||||
if (!chatId) {
|
||||
return createBadRequestResponse('chatId is required')
|
||||
}
|
||||
|
||||
const chat = await getAccessibleCopilotChat(chatId, userId)
|
||||
if (!chat || chat.type !== 'mothership') {
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
const [deletedChat] = await db
|
||||
.delete(copilotChats)
|
||||
.where(
|
||||
and(
|
||||
eq(copilotChats.id, chatId),
|
||||
eq(copilotChats.userId, userId),
|
||||
eq(copilotChats.type, 'mothership')
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
workspaceId: copilotChats.workspaceId,
|
||||
})
|
||||
|
||||
if (!deletedChat) {
|
||||
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (deletedChat.workspaceId) {
|
||||
taskPubSub?.publishStatusChanged({
|
||||
workspaceId: deletedChat.workspaceId,
|
||||
chatId,
|
||||
type: 'deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error deleting mothership chat:', error)
|
||||
return createInternalServerErrorResponse('Failed to delete chat')
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
createInternalServerErrorResponse,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
|
||||
const logger = createLogger('MarkTaskReadAPI')
|
||||
|
||||
const MarkReadSchema = z.object({
|
||||
chatId: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { chatId } = MarkReadSchema.parse(body)
|
||||
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({ lastSeenAt: sql`GREATEST(${copilotChats.updatedAt}, NOW())` })
|
||||
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createBadRequestResponse('chatId is required')
|
||||
}
|
||||
logger.error('Error marking task as read:', error)
|
||||
return createInternalServerErrorResponse('Failed to mark task as read')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
const logger = createLogger('SkillsImportAPI')
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15_000
|
||||
|
||||
const ImportSchema = z.object({
|
||||
url: z.string().url('A valid URL is required'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Converts a standard GitHub file URL to its raw.githubusercontent.com equivalent.
|
||||
*
|
||||
* Supported formats:
|
||||
* github.com/{owner}/{repo}/blob/{branch}/{path}
|
||||
* raw.githubusercontent.com/{owner}/{repo}/{branch}/{path} (passthrough)
|
||||
*/
|
||||
function toRawGitHubUrl(url: string): string {
|
||||
const parsed = new URL(url)
|
||||
|
||||
if (parsed.hostname === 'raw.githubusercontent.com') {
|
||||
return url
|
||||
}
|
||||
|
||||
if (parsed.hostname !== 'github.com') {
|
||||
throw new Error('Only GitHub URLs are supported')
|
||||
}
|
||||
|
||||
const segments = parsed.pathname.split('/').filter(Boolean)
|
||||
if (segments.length < 5 || segments[2] !== 'blob') {
|
||||
throw new Error(
|
||||
'Invalid GitHub URL format. Expected: https://github.com/{owner}/{repo}/blob/{branch}/{path}'
|
||||
)
|
||||
}
|
||||
|
||||
const [owner, repo, , branch, ...pathParts] = segments
|
||||
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${pathParts.join('/')}`
|
||||
}
|
||||
|
||||
/** POST - Fetch a SKILL.md from a GitHub URL and return its raw content */
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized skill import attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { url } = ImportSchema.parse(body)
|
||||
|
||||
let rawUrl: string
|
||||
try {
|
||||
rawUrl = toRawGitHubUrl(url)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Invalid URL'
|
||||
return NextResponse.json({ error: message }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(rawUrl, {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
headers: { Accept: 'text/plain' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(`[${requestId}] GitHub fetch failed`, {
|
||||
status: response.status,
|
||||
url: rawUrl,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch file (HTTP ${response.status}). Is the repository public?` },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('content-length')
|
||||
if (contentLength && Number.parseInt(contentLength, 10) > 100_000) {
|
||||
return NextResponse.json({ error: 'File is too large (max 100KB)' }, { status: 400 })
|
||||
}
|
||||
|
||||
const content = await response.text()
|
||||
|
||||
if (content.length > 100_000) {
|
||||
return NextResponse.json({ error: 'File is too large (max 100KB)' }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ content })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 })
|
||||
}
|
||||
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
|
||||
logger.warn(`[${requestId}] GitHub fetch timed out`)
|
||||
return NextResponse.json({ error: 'Request timed out' }, { status: 504 })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error importing skill`, error)
|
||||
return NextResponse.json({ error: 'Failed to import skill' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ interface ConversationListItemProps {
|
||||
isUnread?: boolean
|
||||
className?: string
|
||||
titleClassName?: string
|
||||
statusIndicatorClassName?: string
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
@@ -17,6 +18,7 @@ export function ConversationListItem({
|
||||
isUnread = false,
|
||||
className,
|
||||
titleClassName,
|
||||
statusIndicatorClassName,
|
||||
actions,
|
||||
}: ConversationListItemProps) {
|
||||
return (
|
||||
@@ -24,10 +26,20 @@ export function ConversationListItem({
|
||||
<span className='relative flex-shrink-0'>
|
||||
<Blimp className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
{isActive && (
|
||||
<span className='-right-[1px] -bottom-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-amber-400' />
|
||||
<span
|
||||
className={cn(
|
||||
'-right-[1px] -bottom-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-amber-400',
|
||||
statusIndicatorClassName
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!isActive && isUnread && (
|
||||
<span className='-right-[1px] -bottom-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-[#33C482]' />
|
||||
<span
|
||||
className={cn(
|
||||
'-right-[1px] -bottom-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-[#33C482]',
|
||||
statusIndicatorClassName
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className={cn('min-w-0 flex-1 truncate', titleClassName)}>{title}</span>
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ interface MothershipChatProps {
|
||||
const LAYOUT_STYLES = {
|
||||
'mothership-view': {
|
||||
scrollContainer:
|
||||
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]',
|
||||
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable_both-edges]',
|
||||
content: 'mx-auto max-w-[42rem] space-y-6',
|
||||
userRow: 'flex flex-col items-end gap-[6px] pt-3',
|
||||
attachmentWidth: 'max-w-[70%]',
|
||||
|
||||
@@ -299,7 +299,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
|
||||
if (!hasMessages && !chatId) {
|
||||
return (
|
||||
<div className='h-full overflow-y-auto bg-[var(--bg)] [scrollbar-gutter:stable]'>
|
||||
<div className='h-full overflow-y-auto bg-[var(--bg)] [scrollbar-gutter:stable_both-edges]'>
|
||||
<div className='flex min-h-full flex-col items-center justify-center px-[24px] pb-[2vh]'>
|
||||
<h1
|
||||
data-tour='home-greeting'
|
||||
|
||||
+292
-183
@@ -30,15 +30,12 @@ import {
|
||||
readPendingCredentialCreateRequest,
|
||||
writeOAuthReturnContext,
|
||||
} from '@/lib/credentials/client-state'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
getServiceConfigByProviderId,
|
||||
type OAuthProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { getCanonicalScopesForProvider, getServiceConfigByProviderId } from '@/lib/oauth'
|
||||
import { getScopeDescription } from '@/lib/oauth/utils'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import { CredentialSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import {
|
||||
useCreateCredentialDraft,
|
||||
useCreateWorkspaceCredential,
|
||||
useDeleteWorkspaceCredential,
|
||||
useRemoveWorkspaceCredentialMember,
|
||||
@@ -82,7 +79,8 @@ export function IntegrationsManager() {
|
||||
const [detailsError, setDetailsError] = useState<string | null>(null)
|
||||
const [selectedDescriptionDraft, setSelectedDescriptionDraft] = useState('')
|
||||
const [selectedDisplayNameDraft, setSelectedDisplayNameDraft] = useState('')
|
||||
const [showCreateOAuthRequiredModal, setShowCreateOAuthRequiredModal] = useState(false)
|
||||
const [createStep, setCreateStep] = useState<1 | 2>(1)
|
||||
const [serviceSearch, setServiceSearch] = useState('')
|
||||
const [copyIdSuccess, setCopyIdSuccess] = useState(false)
|
||||
const [credentialToDelete, setCredentialToDelete] = useState<WorkspaceCredential | null>(null)
|
||||
const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false)
|
||||
@@ -125,6 +123,7 @@ export function IntegrationsManager() {
|
||||
selectedCredential?.id
|
||||
)
|
||||
|
||||
const createDraft = useCreateCredentialDraft()
|
||||
const createCredential = useCreateWorkspaceCredential()
|
||||
const updateCredential = useUpdateWorkspaceCredential()
|
||||
const deleteCredential = useDeleteWorkspaceCredential()
|
||||
@@ -155,12 +154,18 @@ export function IntegrationsManager() {
|
||||
|
||||
const sortedCredentials = useMemo(() => {
|
||||
return [...filteredCredentials].sort((a, b) => {
|
||||
const aDate = new Date(a.updatedAt).getTime()
|
||||
const bDate = new Date(b.updatedAt).getTime()
|
||||
return bDate - aDate
|
||||
const aProvider = a.providerId || ''
|
||||
const bProvider = b.providerId || ''
|
||||
return aProvider.localeCompare(bProvider)
|
||||
})
|
||||
}, [filteredCredentials])
|
||||
|
||||
const filteredAvailableIntegrations = useMemo(() => {
|
||||
if (!searchTerm.trim()) return oauthConnections
|
||||
const normalized = searchTerm.toLowerCase()
|
||||
return oauthConnections.filter((service) => service.name.toLowerCase().includes(normalized))
|
||||
}, [oauthConnections, searchTerm])
|
||||
|
||||
const oauthServiceOptions = useMemo(
|
||||
() =>
|
||||
oauthConnections.map((service) => ({
|
||||
@@ -202,6 +207,14 @@ export function IntegrationsManager() {
|
||||
return getCanonicalScopesForProvider(createOAuthProviderId)
|
||||
}, [selectedOAuthService, createOAuthProviderId])
|
||||
|
||||
const createDisplayScopes = useMemo(
|
||||
() =>
|
||||
createOAuthRequiredScopes.filter(
|
||||
(s) => !s.includes('userinfo.email') && !s.includes('userinfo.profile')
|
||||
),
|
||||
[createOAuthRequiredScopes]
|
||||
)
|
||||
|
||||
const existingOAuthDisplayName = useMemo(() => {
|
||||
const name = createDisplayName.trim()
|
||||
if (!name) return null
|
||||
@@ -237,6 +250,8 @@ export function IntegrationsManager() {
|
||||
...(isDisplayNameDirty ? { displayName: selectedDisplayNameDraft.trim() } : {}),
|
||||
...(isDescriptionDirty ? { description: selectedDescriptionDraft.trim() || null } : {}),
|
||||
})
|
||||
if (isDisplayNameDirty) setSelectedDisplayNameDraft((v) => v.trim())
|
||||
if (isDescriptionDirty) setSelectedDescriptionDraft((v) => v.trim())
|
||||
}
|
||||
|
||||
await refetchCredentials()
|
||||
@@ -254,15 +269,17 @@ export function IntegrationsManager() {
|
||||
setShowUnsavedChangesAlert(true)
|
||||
} else {
|
||||
setSelectedCredentialId(null)
|
||||
setSelectedDescriptionDraft('')
|
||||
setSelectedDisplayNameDraft('')
|
||||
}
|
||||
}, [isDetailsDirty, isSavingDetails])
|
||||
|
||||
const handleDiscardChanges = useCallback(() => {
|
||||
setShowUnsavedChangesAlert(false)
|
||||
setSelectedDescriptionDraft(selectedCredential?.description || '')
|
||||
setSelectedDisplayNameDraft(selectedCredential?.displayName || '')
|
||||
setSelectedDescriptionDraft('')
|
||||
setSelectedDisplayNameDraft('')
|
||||
setSelectedCredentialId(null)
|
||||
}, [selectedCredential])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
@@ -290,7 +307,6 @@ export function IntegrationsManager() {
|
||||
pendingReturnOriginRef.current = request.returnOrigin
|
||||
|
||||
setShowCreateModal(true)
|
||||
setShowCreateOAuthRequiredModal(false)
|
||||
setCreateError(null)
|
||||
setCreateDescription('')
|
||||
setCreateOAuthProviderId(request.providerId)
|
||||
@@ -330,18 +346,6 @@ export function IntegrationsManager() {
|
||||
}
|
||||
}, [workspaceId, applyPendingCredentialCreateRequest])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCredential) {
|
||||
setSelectedDescriptionDraft('')
|
||||
setSelectedDisplayNameDraft('')
|
||||
return
|
||||
}
|
||||
|
||||
setDetailsError(null)
|
||||
setSelectedDescriptionDraft(selectedCredential.description || '')
|
||||
setSelectedDisplayNameDraft(selectedCredential.displayName)
|
||||
}, [selectedCredential])
|
||||
|
||||
const isSelectedAdmin = selectedCredential?.role === 'admin'
|
||||
const selectedOAuthServiceConfig = useMemo(() => {
|
||||
if (
|
||||
@@ -360,28 +364,16 @@ export function IntegrationsManager() {
|
||||
setCreateDescription('')
|
||||
setCreateOAuthProviderId('')
|
||||
setCreateError(null)
|
||||
setShowCreateOAuthRequiredModal(false)
|
||||
setCreateStep(1)
|
||||
setServiceSearch('')
|
||||
pendingReturnOriginRef.current = undefined
|
||||
}
|
||||
|
||||
const handleSelectCredential = (credential: WorkspaceCredential) => {
|
||||
setSelectedCredentialId(credential.id)
|
||||
setDetailsError(null)
|
||||
}
|
||||
|
||||
const handleCreateCredential = async () => {
|
||||
if (!workspaceId) return
|
||||
setCreateError(null)
|
||||
|
||||
if (!selectedOAuthService) {
|
||||
setCreateError('Select an OAuth service before connecting.')
|
||||
return
|
||||
}
|
||||
if (!createDisplayName.trim()) {
|
||||
setCreateError('Display name is required.')
|
||||
return
|
||||
}
|
||||
setShowCreateOAuthRequiredModal(true)
|
||||
setSelectedDescriptionDraft(credential.description || '')
|
||||
setSelectedDisplayNameDraft(credential.displayName)
|
||||
}
|
||||
|
||||
const handleConnectOAuthService = async () => {
|
||||
@@ -398,15 +390,11 @@ export function IntegrationsManager() {
|
||||
|
||||
setCreateError(null)
|
||||
try {
|
||||
await fetch('/api/credentials/draft', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId,
|
||||
providerId: selectedOAuthService.providerId,
|
||||
displayName,
|
||||
description: createDescription.trim() || undefined,
|
||||
}),
|
||||
await createDraft.mutateAsync({
|
||||
workspaceId,
|
||||
providerId: selectedOAuthService.providerId,
|
||||
displayName,
|
||||
description: createDescription.trim() || undefined,
|
||||
})
|
||||
|
||||
const oauthPreCount = credentials.filter(
|
||||
@@ -490,6 +478,8 @@ export function IntegrationsManager() {
|
||||
|
||||
if (selectedCredentialId === credentialToDelete.id) {
|
||||
setSelectedCredentialId(null)
|
||||
setSelectedDescriptionDraft('')
|
||||
setSelectedDisplayNameDraft('')
|
||||
}
|
||||
setShowDeleteConfirmDialog(false)
|
||||
setCredentialToDelete(null)
|
||||
@@ -539,16 +529,12 @@ export function IntegrationsManager() {
|
||||
setDetailsError(null)
|
||||
|
||||
try {
|
||||
await fetch('/api/credentials/draft', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId,
|
||||
providerId: selectedCredential.providerId,
|
||||
displayName: selectedCredential.displayName,
|
||||
description: selectedCredential.description || undefined,
|
||||
credentialId: selectedCredential.id,
|
||||
}),
|
||||
await createDraft.mutateAsync({
|
||||
workspaceId,
|
||||
providerId: selectedCredential.providerId,
|
||||
displayName: selectedCredential.displayName,
|
||||
description: selectedCredential.description || undefined,
|
||||
credentialId: selectedCredential.id,
|
||||
})
|
||||
|
||||
const oauthPreCount = credentials.filter(
|
||||
@@ -618,8 +604,31 @@ export function IntegrationsManager() {
|
||||
}
|
||||
|
||||
const hasCredentials = oauthCredentials && oauthCredentials.length > 0
|
||||
|
||||
const connectedProviderIds = useMemo(
|
||||
() => new Set(oauthCredentials.map((c) => c.providerId).filter(Boolean) as string[]),
|
||||
[oauthCredentials]
|
||||
)
|
||||
|
||||
const showNoResults =
|
||||
searchTerm.trim() && sortedCredentials.length === 0 && oauthCredentials.length > 0
|
||||
searchTerm.trim() &&
|
||||
sortedCredentials.length === 0 &&
|
||||
filteredAvailableIntegrations.length === 0
|
||||
|
||||
const handleAddForProvider = useCallback((providerId: string) => {
|
||||
setCreateOAuthProviderId(providerId)
|
||||
setCreateStep(2)
|
||||
setCreateDisplayName('')
|
||||
setCreateDescription('')
|
||||
setCreateError(null)
|
||||
setShowCreateModal(true)
|
||||
}, [])
|
||||
|
||||
const filteredServices = useMemo(() => {
|
||||
if (!serviceSearch.trim()) return oauthServiceOptions
|
||||
const q = serviceSearch.toLowerCase()
|
||||
return oauthServiceOptions.filter((s) => s.label.toLowerCase().includes(q))
|
||||
}, [oauthServiceOptions, serviceSearch])
|
||||
|
||||
const createModalJsx = (
|
||||
<Modal
|
||||
@@ -630,128 +639,199 @@ export function IntegrationsManager() {
|
||||
}}
|
||||
>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>Connect Integration</ModalHeader>
|
||||
<ModalBody>
|
||||
{(createError || existingOAuthDisplayName) && (
|
||||
<div className='mb-3 flex flex-col gap-2'>
|
||||
{createError && (
|
||||
<Badge variant='red' size='lg' dot className='max-w-full'>
|
||||
{createError}
|
||||
</Badge>
|
||||
{createStep === 1 ? (
|
||||
<>
|
||||
<ModalHeader>Connect Integration</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<div className='flex items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px]'>
|
||||
<Search
|
||||
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<UiInput
|
||||
placeholder='Search services...'
|
||||
value={serviceSearch}
|
||||
onChange={(e) => setServiceSearch(e.target.value)}
|
||||
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'
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className='flex max-h-[320px] flex-col overflow-y-auto'>
|
||||
{filteredServices.map((service) => {
|
||||
const config = getServiceConfigByProviderId(service.value)
|
||||
return (
|
||||
<button
|
||||
key={service.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setCreateOAuthProviderId(service.value)
|
||||
setCreateStep(2)
|
||||
setServiceSearch('')
|
||||
}}
|
||||
className='flex items-center gap-[10px] rounded-[6px] px-[8px] py-[8px] text-left hover:bg-[var(--surface-5)]'
|
||||
>
|
||||
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-[6px] bg-[var(--surface-5)]'>
|
||||
{config ? (
|
||||
createElement(config.icon, { className: 'h-4 w-4' })
|
||||
) : (
|
||||
<span className='font-medium text-[11px] text-[var(--text-tertiary)]'>
|
||||
{service.label.slice(0, 2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className='font-medium text-[15px] text-[var(--text-primary)]'>
|
||||
{service.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{filteredServices.length === 0 && (
|
||||
<div className='py-[24px] text-center text-[13px] text-[var(--text-muted)]'>
|
||||
No services found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setShowCreateModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ModalHeader>
|
||||
<div className='flex items-center gap-[10px]'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setCreateStep(1)
|
||||
setCreateError(null)
|
||||
}}
|
||||
className='flex h-6 w-6 items-center justify-center rounded-[4px] text-[var(--text-muted)] hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
|
||||
aria-label='Back'
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span>
|
||||
Connect{' '}
|
||||
{selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)}
|
||||
</span>
|
||||
</div>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{(createError || existingOAuthDisplayName) && (
|
||||
<div className='mb-3 flex flex-col gap-2'>
|
||||
{createError && (
|
||||
<Badge variant='red' size='lg' dot className='max-w-full'>
|
||||
{createError}
|
||||
</Badge>
|
||||
)}
|
||||
{existingOAuthDisplayName && (
|
||||
<Badge variant='red' size='lg' dot className='max-w-full'>
|
||||
An integration named "{existingOAuthDisplayName.displayName}" already exists.
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{existingOAuthDisplayName && (
|
||||
<Badge variant='red' size='lg' dot className='max-w-full'>
|
||||
An integration named "{existingOAuthDisplayName.displayName}" already exists.
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<div className='flex flex-col gap-[10px]'>
|
||||
<div>
|
||||
<Label>Account</Label>
|
||||
<div className='mt-[6px]'>
|
||||
<Combobox
|
||||
options={oauthServiceOptions}
|
||||
value={
|
||||
oauthServiceOptions.find((option) => option.value === createOAuthProviderId)
|
||||
?.label || ''
|
||||
}
|
||||
selectedValue={createOAuthProviderId}
|
||||
onChange={(value) => {
|
||||
setCreateOAuthProviderId(value)
|
||||
setCreateError(null)
|
||||
}}
|
||||
placeholder='Select OAuth service'
|
||||
searchable
|
||||
searchPlaceholder='Search services...'
|
||||
overlayContent={
|
||||
createOAuthProviderId
|
||||
? (() => {
|
||||
const config = getServiceConfigByProviderId(createOAuthProviderId)
|
||||
const label =
|
||||
oauthServiceOptions.find((o) => o.value === createOAuthProviderId)
|
||||
?.label || ''
|
||||
return (
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{config &&
|
||||
createElement(config.icon, {
|
||||
className: 'h-[14px] w-[14px] flex-shrink-0',
|
||||
})}
|
||||
<span className='truncate'>{label}</span>
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
: undefined
|
||||
}
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<div className='flex h-[40px] w-[40px] flex-shrink-0 items-center justify-center rounded-[8px] bg-[var(--surface-5)]'>
|
||||
{selectedOAuthService &&
|
||||
createElement(selectedOAuthService.icon, { className: 'h-[18px] w-[18px]' })}
|
||||
</div>
|
||||
<div>
|
||||
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Connect your {selectedOAuthService?.name} account
|
||||
</p>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Grant access to use {selectedOAuthService?.name} in your workflows
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createDisplayScopes.length > 0 && (
|
||||
<div className='rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)]'>
|
||||
<div className='border-[var(--border-1)] border-b px-[14px] py-[10px]'>
|
||||
<h4 className='font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
Permissions requested
|
||||
</h4>
|
||||
</div>
|
||||
<ul className='max-h-[200px] space-y-[10px] overflow-y-auto px-[14px] py-[12px]'>
|
||||
{createDisplayScopes.map((scope) => (
|
||||
<li key={scope} className='flex items-start gap-[10px]'>
|
||||
<div className='mt-[2px] flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
|
||||
<Check className='h-[10px] w-[10px] text-[var(--text-primary)]' />
|
||||
</div>
|
||||
<span className='text-[12px] text-[var(--text-primary)]'>
|
||||
{getScopeDescription(scope)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
Display name<span className='ml-1'>*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={createDisplayName}
|
||||
onChange={(event) => setCreateDisplayName(event.target.value)}
|
||||
placeholder='Integration name'
|
||||
autoComplete='off'
|
||||
data-lpignore='true'
|
||||
className='mt-[6px]'
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={createDescription}
|
||||
onChange={(event) => setCreateDescription(event.target.value)}
|
||||
placeholder='Optional description'
|
||||
maxLength={500}
|
||||
autoComplete='off'
|
||||
data-lpignore='true'
|
||||
className='mt-[6px] min-h-[80px] resize-none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
Display name<span className='ml-1'>*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={createDisplayName}
|
||||
onChange={(event) => setCreateDisplayName(event.target.value)}
|
||||
placeholder='Integration name'
|
||||
autoComplete='off'
|
||||
data-lpignore='true'
|
||||
className='mt-[6px]'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={createDescription}
|
||||
onChange={(event) => setCreateDescription(event.target.value)}
|
||||
placeholder='Optional description'
|
||||
maxLength={500}
|
||||
autoComplete='off'
|
||||
data-lpignore='true'
|
||||
className='mt-[6px] min-h-[80px] resize-none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setShowCreateModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleCreateCredential}
|
||||
disabled={
|
||||
!createOAuthProviderId ||
|
||||
!createDisplayName.trim() ||
|
||||
connectOAuthService.isPending ||
|
||||
Boolean(existingOAuthDisplayName) ||
|
||||
disconnectOAuthService.isPending
|
||||
}
|
||||
>
|
||||
{connectOAuthService.isPending ? 'Connecting...' : 'Connect'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => {
|
||||
setCreateStep(1)
|
||||
setCreateError(null)
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleConnectOAuthService}
|
||||
disabled={
|
||||
!createOAuthProviderId ||
|
||||
!createDisplayName.trim() ||
|
||||
connectOAuthService.isPending ||
|
||||
Boolean(existingOAuthDisplayName) ||
|
||||
disconnectOAuthService.isPending
|
||||
}
|
||||
>
|
||||
{connectOAuthService.isPending ? 'Connecting...' : 'Connect'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
const oauthRequiredModalJsx = showCreateOAuthRequiredModal && createOAuthProviderId && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showCreateOAuthRequiredModal}
|
||||
onClose={() => setShowCreateOAuthRequiredModal(false)}
|
||||
provider={createOAuthProviderId as OAuthProvider}
|
||||
toolName={resolveProviderLabel(createOAuthProviderId)}
|
||||
requiredScopes={createOAuthRequiredScopes}
|
||||
newScopes={[]}
|
||||
serviceId={selectedOAuthService?.id || createOAuthProviderId}
|
||||
onConnect={async () => {
|
||||
await handleConnectOAuthService()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const handleCloseDeleteDialog = () => {
|
||||
setShowDeleteConfirmDialog(false)
|
||||
setCredentialToDelete(null)
|
||||
@@ -1083,7 +1163,6 @@ export function IntegrationsManager() {
|
||||
</div>
|
||||
|
||||
{createModalJsx}
|
||||
{oauthRequiredModalJsx}
|
||||
{deleteConfirmDialogJsx}
|
||||
{unsavedChangesAlertJsx}
|
||||
</>
|
||||
@@ -1124,17 +1203,12 @@ export function IntegrationsManager() {
|
||||
<CredentialSkeleton />
|
||||
<CredentialSkeleton />
|
||||
</div>
|
||||
) : !hasCredentials ? (
|
||||
<div className='flex h-full items-center justify-center text-[14px] text-[var(--text-muted)]'>
|
||||
Click "Connect" above to get started
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{sortedCredentials.map((credential) => {
|
||||
const serviceConfig = credential.providerId
|
||||
? getServiceConfigByProviderId(credential.providerId)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div key={credential.id} className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex min-w-0 items-center gap-[10px]'>
|
||||
@@ -1169,18 +1243,53 @@ export function IntegrationsManager() {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{showNoResults && (
|
||||
<div className='py-[16px] text-center text-[14px] text-[var(--text-muted)]'>
|
||||
No integrations found matching “{searchTerm}”
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredAvailableIntegrations.length > 0 && (
|
||||
<div
|
||||
className={`flex flex-col gap-[8px]${hasCredentials || showNoResults ? ' mt-[8px] border-[var(--border)] border-t pt-[16px]' : ''}`}
|
||||
>
|
||||
<p className='mb-[4px] font-medium text-[12px] text-[var(--text-muted)]'>
|
||||
Available integrations
|
||||
</p>
|
||||
{filteredAvailableIntegrations.map((service) => {
|
||||
const serviceConfig = getServiceConfigByProviderId(service.providerId)
|
||||
const isConnected = connectedProviderIds.has(service.providerId)
|
||||
return (
|
||||
<div
|
||||
key={service.providerId}
|
||||
className='flex items-center justify-between gap-[12px]'
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-[10px]'>
|
||||
{serviceConfig && (
|
||||
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-[6px] bg-[var(--surface-5)]'>
|
||||
{createElement(serviceConfig.icon, { className: 'h-4 w-4' })}
|
||||
</div>
|
||||
)}
|
||||
<span className='truncate font-medium text-[15px]'>{service.name}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => handleAddForProvider(service.providerId)}
|
||||
>
|
||||
{isConnected ? 'Add account' : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createModalJsx}
|
||||
{oauthRequiredModalJsx}
|
||||
{deleteConfirmDialogJsx}
|
||||
</>
|
||||
)
|
||||
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button, Input, Label, Textarea } from '@/components/emcn'
|
||||
import { Upload } from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { extractSkillFromZip, parseSkillMarkdown } from './utils'
|
||||
|
||||
interface ImportedSkill {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
}
|
||||
|
||||
interface SkillImportProps {
|
||||
onImport: (data: ImportedSkill) => void
|
||||
}
|
||||
|
||||
type ImportState = 'idle' | 'loading' | 'error'
|
||||
|
||||
const ACCEPTED_EXTENSIONS = ['.md', '.zip']
|
||||
|
||||
function isAcceptedFile(file: File): boolean {
|
||||
const name = file.name.toLowerCase()
|
||||
return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext))
|
||||
}
|
||||
|
||||
export function SkillImport({ onImport }: SkillImportProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [dragCounter, setDragCounter] = useState(0)
|
||||
const isDragging = dragCounter > 0
|
||||
const [fileState, setFileState] = useState<ImportState>('idle')
|
||||
const [fileError, setFileError] = useState('')
|
||||
|
||||
const [githubUrl, setGithubUrl] = useState('')
|
||||
const [githubState, setGithubState] = useState<ImportState>('idle')
|
||||
const [githubError, setGithubError] = useState('')
|
||||
|
||||
const [pasteContent, setPasteContent] = useState('')
|
||||
const [pasteError, setPasteError] = useState('')
|
||||
|
||||
const processFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (!isAcceptedFile(file)) {
|
||||
setFileError('Unsupported file type. Use .md or .zip files.')
|
||||
setFileState('error')
|
||||
return
|
||||
}
|
||||
|
||||
setFileState('loading')
|
||||
setFileError('')
|
||||
|
||||
try {
|
||||
let rawContent: string
|
||||
|
||||
if (file.name.toLowerCase().endsWith('.zip')) {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setFileError('ZIP file is too large (max 5 MB)')
|
||||
setFileState('error')
|
||||
return
|
||||
}
|
||||
rawContent = await extractSkillFromZip(file)
|
||||
} else {
|
||||
rawContent = await file.text()
|
||||
}
|
||||
|
||||
const parsed = parseSkillMarkdown(rawContent)
|
||||
setFileState('idle')
|
||||
onImport(parsed)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to process file'
|
||||
setFileError(message)
|
||||
setFileState('error')
|
||||
}
|
||||
},
|
||||
[onImport]
|
||||
)
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) processFile(file)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
},
|
||||
[processFile]
|
||||
)
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter((prev) => prev + 1)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter((prev) => prev - 1)
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter(0)
|
||||
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) processFile(file)
|
||||
},
|
||||
[processFile]
|
||||
)
|
||||
|
||||
const handleGithubImport = useCallback(async () => {
|
||||
const trimmed = githubUrl.trim()
|
||||
if (!trimmed) {
|
||||
setGithubError('Please enter a GitHub URL')
|
||||
setGithubState('error')
|
||||
return
|
||||
}
|
||||
|
||||
setGithubState('loading')
|
||||
setGithubError('')
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/skills/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: trimmed }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `Import failed (HTTP ${res.status})`)
|
||||
}
|
||||
|
||||
const parsed = parseSkillMarkdown(data.content)
|
||||
setGithubState('idle')
|
||||
onImport(parsed)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to import from GitHub'
|
||||
setGithubError(message)
|
||||
setGithubState('error')
|
||||
}
|
||||
}, [githubUrl, onImport])
|
||||
|
||||
const handlePasteImport = useCallback(() => {
|
||||
const trimmed = pasteContent.trim()
|
||||
if (!trimmed) {
|
||||
setPasteError('Please paste some content first')
|
||||
return
|
||||
}
|
||||
|
||||
setPasteError('')
|
||||
const parsed = parseSkillMarkdown(trimmed)
|
||||
onImport(parsed)
|
||||
}, [pasteContent, onImport])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[18px]'>
|
||||
{/* File drop zone */}
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label className='font-medium text-[14px]'>Upload File</Label>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
disabled={fileState === 'loading'}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer flex-col items-center justify-center gap-[8px] rounded-[8px] border border-dashed px-[16px] py-[32px] transition-colors',
|
||||
'border-[var(--border-1)] bg-[var(--surface-1)] hover:bg-[var(--surface-4)]',
|
||||
isDragging && 'border-[var(--surface-7)] bg-[var(--surface-4)]',
|
||||
fileState === 'loading' && 'pointer-events-none opacity-60'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='.md,.zip'
|
||||
onChange={handleFileChange}
|
||||
className='hidden'
|
||||
/>
|
||||
{fileState === 'loading' ? (
|
||||
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
|
||||
) : (
|
||||
<Upload className='h-[20px] w-[20px] text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
<div className='flex flex-col gap-[2px] text-center'>
|
||||
<span className='text-[14px] text-[var(--text-primary)]'>
|
||||
{isDragging ? 'Drop file here' : 'Drop file here or click to browse'}
|
||||
</span>
|
||||
<span className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
.md file with YAML frontmatter, or .zip containing a SKILL.md
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{fileError && <p className='text-[13px] text-[var(--text-error)]'>{fileError}</p>}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* GitHub URL */}
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-github-url' className='font-medium text-[14px]'>
|
||||
Import from GitHub
|
||||
</Label>
|
||||
<div className='flex gap-[8px]'>
|
||||
<Input
|
||||
id='skill-github-url'
|
||||
placeholder='https://github.com/owner/repo/blob/main/SKILL.md'
|
||||
value={githubUrl}
|
||||
onChange={(e) => {
|
||||
setGithubUrl(e.target.value)
|
||||
if (githubError) setGithubError('')
|
||||
}}
|
||||
className='flex-1'
|
||||
disabled={githubState === 'loading'}
|
||||
/>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleGithubImport}
|
||||
disabled={githubState === 'loading' || !githubUrl.trim()}
|
||||
>
|
||||
{githubState === 'loading' ? (
|
||||
<Loader2 className='h-[14px] w-[14px] animate-spin' />
|
||||
) : (
|
||||
'Fetch'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{githubError && <p className='text-[13px] text-[var(--text-error)]'>{githubError}</p>}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Paste content */}
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-paste' className='font-medium text-[14px]'>
|
||||
Paste SKILL.md Content
|
||||
</Label>
|
||||
<Textarea
|
||||
id='skill-paste'
|
||||
placeholder={
|
||||
'---\nname: my-skill\ndescription: What this skill does\n---\n\n# Instructions...'
|
||||
}
|
||||
value={pasteContent}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setPasteContent(e.target.value)
|
||||
if (pasteError) setPasteError('')
|
||||
}}
|
||||
className='min-h-[120px] resize-y font-mono text-[14px]'
|
||||
/>
|
||||
{pasteError && <p className='text-[13px] text-[var(--text-error)]'>{pasteError}</p>}
|
||||
<div className='flex justify-end'>
|
||||
<Button variant='default' onClick={handlePasteImport} disabled={!pasteContent.trim()}>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return (
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<div className='h-px flex-1 bg-[var(--border-1)]' />
|
||||
<span className='text-[12px] text-[var(--text-tertiary)]'>or</span>
|
||||
<div className='h-px flex-1 bg-[var(--border-1)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+138
-89
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
@@ -12,10 +12,15 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTabs,
|
||||
ModalTabsContent,
|
||||
ModalTabsList,
|
||||
ModalTabsTrigger,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import type { SkillDefinition } from '@/hooks/queries/skills'
|
||||
import { useCreateSkill, useUpdateSkill } from '@/hooks/queries/skills'
|
||||
import { SkillImport } from './skill-import'
|
||||
|
||||
interface SkillModalProps {
|
||||
open: boolean
|
||||
@@ -34,6 +39,8 @@ interface FieldErrors {
|
||||
general?: string
|
||||
}
|
||||
|
||||
type TabValue = 'create' | 'import'
|
||||
|
||||
export function SkillModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -52,6 +59,7 @@ export function SkillModal({
|
||||
const [content, setContent] = useState('')
|
||||
const [errors, setErrors] = useState<FieldErrors>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('create')
|
||||
const [prevOpen, setPrevOpen] = useState(false)
|
||||
const [prevInitialValues, setPrevInitialValues] = useState(initialValues)
|
||||
|
||||
@@ -60,6 +68,7 @@ export function SkillModal({
|
||||
setDescription(initialValues?.description ?? '')
|
||||
setContent(initialValues?.content ?? '')
|
||||
setErrors({})
|
||||
setActiveTab('create')
|
||||
}
|
||||
if (open !== prevOpen) setPrevOpen(open)
|
||||
if (initialValues !== prevInitialValues) setPrevInitialValues(initialValues)
|
||||
@@ -124,97 +133,137 @@ export function SkillModal({
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = useCallback(
|
||||
(data: { name: string; description: string; content: string }) => {
|
||||
setName(data.name)
|
||||
setDescription(data.description)
|
||||
setContent(data.content)
|
||||
setErrors({})
|
||||
setActiveTab('create')
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const isEditing = !!initialValues
|
||||
|
||||
const createForm = (
|
||||
<div className='flex flex-col gap-[18px]'>
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-name' className='font-medium text-[14px]'>
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id='skill-name'
|
||||
placeholder='my-skill-name'
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (errors.name || errors.general)
|
||||
setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
|
||||
}}
|
||||
/>
|
||||
{errors.name ? (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>{errors.name}</p>
|
||||
) : (
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>
|
||||
Lowercase letters, numbers, and hyphens (e.g. my-skill)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-description' className='font-medium text-[14px]'>
|
||||
Description
|
||||
</Label>
|
||||
<Input
|
||||
id='skill-description'
|
||||
placeholder='What this skill does and when to use it...'
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value)
|
||||
if (errors.description || errors.general)
|
||||
setErrors((prev) => ({ ...prev, description: undefined, general: undefined }))
|
||||
}}
|
||||
maxLength={1024}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>{errors.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-content' className='font-medium text-[14px]'>
|
||||
Content
|
||||
</Label>
|
||||
<Textarea
|
||||
id='skill-content'
|
||||
placeholder='Skill instructions in markdown...'
|
||||
value={content}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
if (errors.content || errors.general)
|
||||
setErrors((prev) => ({ ...prev, content: undefined, general: undefined }))
|
||||
}}
|
||||
className='min-h-[200px] resize-y font-mono text-[14px]'
|
||||
/>
|
||||
{errors.content && <p className='text-[13px] text-[var(--text-error)]'>{errors.content}</p>}
|
||||
</div>
|
||||
|
||||
{errors.general && <p className='text-[13px] text-[var(--text-error)]'>{errors.general}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
const footer = (
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
{isEditing && onDelete ? (
|
||||
<Button variant='destructive' onClick={() => onDelete(initialValues.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className='flex gap-2'>
|
||||
<Button variant='default' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='primary' onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
{saving ? 'Saving...' : isEditing ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent size='lg'>
|
||||
<ModalHeader>{initialValues ? 'Edit Skill' : 'Create Skill'}</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-[18px]'>
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-name' className='font-medium text-[14px]'>
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id='skill-name'
|
||||
placeholder='my-skill-name'
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (errors.name || errors.general)
|
||||
setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
|
||||
}}
|
||||
/>
|
||||
{errors.name ? (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>{errors.name}</p>
|
||||
) : (
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>
|
||||
Lowercase letters, numbers, and hyphens (e.g. my-skill)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-description' className='font-medium text-[14px]'>
|
||||
Description
|
||||
</Label>
|
||||
<Input
|
||||
id='skill-description'
|
||||
placeholder='What this skill does and when to use it...'
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value)
|
||||
if (errors.description || errors.general)
|
||||
setErrors((prev) => ({ ...prev, description: undefined, general: undefined }))
|
||||
}}
|
||||
maxLength={1024}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>{errors.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='skill-content' className='font-medium text-[14px]'>
|
||||
Content
|
||||
</Label>
|
||||
<Textarea
|
||||
id='skill-content'
|
||||
placeholder='Skill instructions in markdown...'
|
||||
value={content}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
if (errors.content || errors.general)
|
||||
setErrors((prev) => ({ ...prev, content: undefined, general: undefined }))
|
||||
}}
|
||||
className='min-h-[200px] resize-y font-mono text-[14px]'
|
||||
/>
|
||||
{errors.content && (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>{errors.content}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errors.general && (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>{errors.general}</p>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
{initialValues && onDelete ? (
|
||||
<Button variant='destructive' onClick={() => onDelete(initialValues.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className='flex gap-2'>
|
||||
<Button variant='default' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='primary' onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
{saving ? 'Saving...' : initialValues ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<ModalHeader>Edit Skill</ModalHeader>
|
||||
<ModalBody>{createForm}</ModalBody>
|
||||
{footer}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ModalHeader>Add Skill</ModalHeader>
|
||||
<ModalTabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as TabValue)}
|
||||
className='flex min-h-0 flex-1 flex-col'
|
||||
>
|
||||
<ModalTabsList activeValue={activeTab}>
|
||||
<ModalTabsTrigger value='create'>Create</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='import'>Import</ModalTabsTrigger>
|
||||
</ModalTabsList>
|
||||
<ModalBody>
|
||||
<ModalTabsContent value='create'>{createForm}</ModalTabsContent>
|
||||
<ModalTabsContent value='import'>
|
||||
<SkillImport onImport={handleImport} />
|
||||
</ModalTabsContent>
|
||||
</ModalBody>
|
||||
</ModalTabs>
|
||||
{activeTab === 'create' && footer}
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import JSZip from 'jszip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractSkillFromZip, parseSkillMarkdown } from './utils'
|
||||
|
||||
describe('parseSkillMarkdown', () => {
|
||||
it('parses standard SKILL.md with name, description, and body', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: my-skill',
|
||||
'description: Does something useful',
|
||||
'---',
|
||||
'',
|
||||
'# Instructions',
|
||||
'Use this skill to do things.',
|
||||
].join('\n')
|
||||
|
||||
expect(parseSkillMarkdown(input)).toEqual({
|
||||
name: 'my-skill',
|
||||
description: 'Does something useful',
|
||||
content: '# Instructions\nUse this skill to do things.',
|
||||
})
|
||||
})
|
||||
|
||||
it('strips single and double quotes from frontmatter values', () => {
|
||||
const input = '---\nname: \'my-skill\'\ndescription: "A quoted description"\n---\nBody'
|
||||
|
||||
expect(parseSkillMarkdown(input)).toEqual({
|
||||
name: 'my-skill',
|
||||
description: 'A quoted description',
|
||||
content: 'Body',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves colons inside description values', () => {
|
||||
const input = '---\nname: api-tool\ndescription: API key: required for auth\n---\nBody'
|
||||
|
||||
expect(parseSkillMarkdown(input)).toEqual({
|
||||
name: 'api-tool',
|
||||
description: 'API key: required for auth',
|
||||
content: 'Body',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores unknown frontmatter fields', () => {
|
||||
const input = '---\nname: x\ndescription: y\nauthor: someone\nversion: 2\n---\nBody'
|
||||
|
||||
const result = parseSkillMarkdown(input)
|
||||
expect(result.name).toBe('x')
|
||||
expect(result.description).toBe('y')
|
||||
expect(result.content).toBe('Body')
|
||||
})
|
||||
|
||||
it('infers name from heading when frontmatter has no name field', () => {
|
||||
const input =
|
||||
'---\ndescription: A tool for blocks\nargument-hint: <name>\n---\n\n# Add Block Skill\n\nContent here.'
|
||||
|
||||
expect(parseSkillMarkdown(input)).toEqual({
|
||||
name: 'add-block-skill',
|
||||
description: 'A tool for blocks',
|
||||
content: '# Add Block Skill\n\nContent here.',
|
||||
})
|
||||
})
|
||||
|
||||
it('infers name from heading when there is no frontmatter at all', () => {
|
||||
const input = '# My Cool Tool\n\nSome instructions.'
|
||||
|
||||
expect(parseSkillMarkdown(input)).toEqual({
|
||||
name: 'my-cool-tool',
|
||||
description: '',
|
||||
content: '# My Cool Tool\n\nSome instructions.',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty name when there is no frontmatter and no heading', () => {
|
||||
const input = 'Just some plain text without any structure.'
|
||||
|
||||
expect(parseSkillMarkdown(input)).toEqual({
|
||||
name: '',
|
||||
description: '',
|
||||
content: 'Just some plain text without any structure.',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles empty input', () => {
|
||||
expect(parseSkillMarkdown('')).toEqual({
|
||||
name: '',
|
||||
description: '',
|
||||
content: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles frontmatter with empty name value', () => {
|
||||
const input = '---\nname:\ndescription: Has a description\n---\n\n# Fallback Heading\nBody'
|
||||
|
||||
const result = parseSkillMarkdown(input)
|
||||
expect(result.name).toBe('fallback-heading')
|
||||
expect(result.description).toBe('Has a description')
|
||||
})
|
||||
|
||||
it('handles frontmatter with no body', () => {
|
||||
const input = '---\nname: solo\ndescription: Just frontmatter\n---'
|
||||
|
||||
expect(parseSkillMarkdown(input)).toEqual({
|
||||
name: 'solo',
|
||||
description: 'Just frontmatter',
|
||||
content: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles unclosed frontmatter as plain content', () => {
|
||||
const input = '---\nname: broken\nno closing delimiter'
|
||||
|
||||
const result = parseSkillMarkdown(input)
|
||||
expect(result.name).toBe('')
|
||||
expect(result.content).toBe(input)
|
||||
})
|
||||
|
||||
it('trims whitespace from input', () => {
|
||||
const input = '\n\n ---\nname: trimmed\ndescription: yes\n---\nBody \n\n'
|
||||
|
||||
const result = parseSkillMarkdown(input)
|
||||
expect(result.name).toBe('trimmed')
|
||||
expect(result.content).toBe('Body')
|
||||
})
|
||||
|
||||
it('truncates inferred heading names to 64 characters', () => {
|
||||
const longHeading = `# ${'A'.repeat(100)}`
|
||||
const result = parseSkillMarkdown(longHeading)
|
||||
expect(result.name.length).toBeLessThanOrEqual(64)
|
||||
})
|
||||
|
||||
it('sanitizes special characters in inferred heading names', () => {
|
||||
const input = '# Hello, World! (v2) — Updated'
|
||||
const result = parseSkillMarkdown(input)
|
||||
expect(result.name).toBe('hello-world-v2-updated')
|
||||
})
|
||||
|
||||
it('handles h2 and h3 headings for name inference', () => {
|
||||
expect(parseSkillMarkdown('## Sub Heading').name).toBe('sub-heading')
|
||||
expect(parseSkillMarkdown('### Third Level').name).toBe('third-level')
|
||||
})
|
||||
|
||||
it('does not match h4+ headings for name inference', () => {
|
||||
expect(parseSkillMarkdown('#### Too Deep').name).toBe('')
|
||||
})
|
||||
|
||||
it('uses first heading even when multiple exist', () => {
|
||||
const input = '# First\n\n## Second\n\n### Third'
|
||||
expect(parseSkillMarkdown(input).name).toBe('first')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractSkillFromZip', () => {
|
||||
async function makeZipBuffer(files: Record<string, string>): Promise<Uint8Array> {
|
||||
const zip = new JSZip()
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
zip.file(path, content)
|
||||
}
|
||||
return zip.generateAsync({ type: 'uint8array' })
|
||||
}
|
||||
|
||||
it('extracts SKILL.md at root level', async () => {
|
||||
const data = await makeZipBuffer({ 'SKILL.md': '---\nname: root\n---\nContent' })
|
||||
const content = await extractSkillFromZip(data)
|
||||
expect(content).toBe('---\nname: root\n---\nContent')
|
||||
})
|
||||
|
||||
it('extracts SKILL.md from a nested directory', async () => {
|
||||
const data = await makeZipBuffer({ 'my-skill/SKILL.md': '---\nname: nested\n---\nBody' })
|
||||
const content = await extractSkillFromZip(data)
|
||||
expect(content).toBe('---\nname: nested\n---\nBody')
|
||||
})
|
||||
|
||||
it('prefers the shallowest SKILL.md when multiple exist', async () => {
|
||||
const data = await makeZipBuffer({
|
||||
'deep/nested/SKILL.md': 'deep',
|
||||
'SKILL.md': 'root',
|
||||
'other/SKILL.md': 'other',
|
||||
})
|
||||
const content = await extractSkillFromZip(data)
|
||||
expect(content).toBe('root')
|
||||
})
|
||||
|
||||
it('throws when no SKILL.md is found', async () => {
|
||||
const data = await makeZipBuffer({ 'README.md': 'No skill here' })
|
||||
await expect(extractSkillFromZip(data)).rejects.toThrow('No SKILL.md file found')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import JSZip from 'jszip'
|
||||
|
||||
interface ParsedSkill {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/
|
||||
|
||||
/**
|
||||
* Parses a SKILL.md string with optional YAML frontmatter into structured fields.
|
||||
*
|
||||
* Expected format:
|
||||
* ```
|
||||
* ---
|
||||
* name: my-skill
|
||||
* description: What this skill does
|
||||
* ---
|
||||
* # Markdown content here...
|
||||
* ```
|
||||
*
|
||||
* If no frontmatter is present, the entire text becomes the content field.
|
||||
*/
|
||||
export function parseSkillMarkdown(raw: string): ParsedSkill {
|
||||
const trimmed = raw.replace(/\r\n/g, '\n').trim()
|
||||
const match = trimmed.match(FRONTMATTER_REGEX)
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
name: inferNameFromHeading(trimmed),
|
||||
description: '',
|
||||
content: trimmed,
|
||||
}
|
||||
}
|
||||
|
||||
const frontmatter = match[1]
|
||||
const body = (match[2] ?? '').trim()
|
||||
|
||||
let name = ''
|
||||
let description = ''
|
||||
|
||||
for (const line of frontmatter.split('\n')) {
|
||||
const colonIdx = line.indexOf(':')
|
||||
if (colonIdx === -1) continue
|
||||
|
||||
const key = line.slice(0, colonIdx).trim().toLowerCase()
|
||||
const value = line
|
||||
.slice(colonIdx + 1)
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
|
||||
if (key === 'name') {
|
||||
name = value
|
||||
} else if (key === 'description') {
|
||||
description = value
|
||||
}
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
name = inferNameFromHeading(body)
|
||||
}
|
||||
|
||||
return { name, description, content: body }
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a kebab-case name from the first markdown heading (e.g. `# Add Block Skill` -> `add-block-skill`).
|
||||
*/
|
||||
function inferNameFromHeading(markdown: string): string {
|
||||
const headingMatch = markdown.match(/^#{1,3}\s+(.+)$/m)
|
||||
if (!headingMatch) return ''
|
||||
|
||||
return headingMatch[1]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 64)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the SKILL.md content from a ZIP archive.
|
||||
* Searches for a file named SKILL.md at any depth within the archive.
|
||||
* Accepts File, Blob, ArrayBuffer, or Uint8Array (anything JSZip supports).
|
||||
*/
|
||||
export async function extractSkillFromZip(
|
||||
data: File | Blob | ArrayBuffer | Uint8Array
|
||||
): Promise<string> {
|
||||
const zip = await JSZip.loadAsync(data)
|
||||
|
||||
const candidates: string[] = []
|
||||
zip.forEach((relativePath, entry) => {
|
||||
if (!entry.dir && relativePath.endsWith('SKILL.md')) {
|
||||
candidates.push(relativePath)
|
||||
}
|
||||
})
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new Error('No SKILL.md file found in the ZIP archive')
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
const depthA = a.split('/').length
|
||||
const depthB = b.split('/').length
|
||||
return depthA - depthB
|
||||
})
|
||||
|
||||
const content = await zip.file(candidates[0])!.async('string')
|
||||
return content
|
||||
}
|
||||
+1
-1
@@ -103,7 +103,7 @@ export function CreditBalance({
|
||||
{canPurchase && (
|
||||
<Modal open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<ModalTrigger asChild>
|
||||
<Button variant='default' className='h-[32px] text-[13px]'>
|
||||
<Button variant='active' className='h-[32px] text-[13px]'>
|
||||
Add Credits
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
|
||||
+29
-28
@@ -711,7 +711,7 @@ export function Subscription() {
|
||||
const showProCard = !isOnMaxTier
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-2 gap-[10px]'>
|
||||
<div className='grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-[10px]'>
|
||||
{showProCard && (
|
||||
<CreditPlanCard
|
||||
name='Pro'
|
||||
@@ -801,6 +801,15 @@ export function Subscription() {
|
||||
features={MAX_PLAN_FEATURES}
|
||||
isLegacyPlan={isLegacyPlan && isOnMaxTier}
|
||||
/>
|
||||
{hasEnterprise && (
|
||||
<PlanCard
|
||||
name='Enterprise'
|
||||
price=''
|
||||
features={ENTERPRISE_PLAN_FEATURES}
|
||||
buttonText='Contact'
|
||||
onButtonClick={() => window.open(CONSTANTS.TYPEFORM_ENTERPRISE_URL, '_blank')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
@@ -924,24 +933,26 @@ export function Subscription() {
|
||||
|
||||
{/* Billing details section */}
|
||||
{(subscription.isPaid || (!isLoading && isTeamAdmin)) && (
|
||||
<div className='flex flex-col gap-[14px] rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[14px] py-[12px]'>
|
||||
<div className='flex flex-col'>
|
||||
{subscription.isPaid && permissions.canViewUsageInfo && (
|
||||
<CreditBalance
|
||||
balance={subscriptionData?.data?.creditBalance ?? 0}
|
||||
canPurchase={hasUsablePaidAccess && permissions.canEditUsageLimit}
|
||||
entityType={
|
||||
subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'
|
||||
}
|
||||
isLoading={isLoading}
|
||||
onPurchaseComplete={() => refetchSubscription()}
|
||||
/>
|
||||
<div className='py-[2px]'>
|
||||
<CreditBalance
|
||||
balance={subscriptionData?.data?.creditBalance ?? 0}
|
||||
canPurchase={hasUsablePaidAccess && permissions.canEditUsageLimit}
|
||||
entityType={
|
||||
subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'
|
||||
}
|
||||
isLoading={isLoading}
|
||||
onPurchaseComplete={() => refetchSubscription()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subscription.isPaid &&
|
||||
subscriptionData?.data?.periodEnd &&
|
||||
!permissions.showTeamMemberView &&
|
||||
!permissions.isEnterpriseMember && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center justify-between border-[var(--border-1)] border-t pt-[16px]'>
|
||||
<Label>{isCancelledAtPeriodEnd ? 'Access Until' : 'Next Billing Date'}</Label>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
|
||||
@@ -950,16 +961,18 @@ export function Subscription() {
|
||||
)}
|
||||
|
||||
{subscription.isPaid && permissions.canViewUsageInfo && (
|
||||
<BillingUsageNotificationsToggle />
|
||||
<div className='border-[var(--border-1)] border-t pt-[16px]'>
|
||||
<BillingUsageNotificationsToggle />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subscription.isPaid &&
|
||||
!permissions.showTeamMemberView &&
|
||||
!permissions.isEnterpriseMember && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center justify-between border-[var(--border-1)] border-t pt-[16px]'>
|
||||
<Label>Invoices</Label>
|
||||
<Button
|
||||
variant='outline'
|
||||
variant='active'
|
||||
size='sm'
|
||||
disabled={openBillingPortal.isPending}
|
||||
onClick={() => {
|
||||
@@ -995,7 +1008,7 @@ export function Subscription() {
|
||||
)}
|
||||
|
||||
{!isLoading && isTeamAdmin && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center justify-between border-[var(--border-1)] border-t pt-[16px]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Label htmlFor='billed-account'>Billed Account</Label>
|
||||
<Tooltip.Root>
|
||||
@@ -1040,18 +1053,6 @@ export function Subscription() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enterprise */}
|
||||
{hasEnterprise && (
|
||||
<PlanCard
|
||||
name='Enterprise'
|
||||
price=''
|
||||
features={ENTERPRISE_PLAN_FEATURES}
|
||||
buttonText='Contact'
|
||||
onButtonClick={() => window.open(CONSTANTS.TYPEFORM_ENTERPRISE_URL, '_blank')}
|
||||
inlineButton
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface NavigationItem {
|
||||
selfHostedOverride?: boolean
|
||||
requiresSuperUser?: boolean
|
||||
requiresAdminRole?: boolean
|
||||
/** Show in the sidebar even when the user lacks the required plan, with an upgrade badge. */
|
||||
showWhenLocked?: boolean
|
||||
externalUrl?: string
|
||||
}
|
||||
|
||||
@@ -137,13 +139,13 @@ export const allNavigationItems: NavigationItem[] = [
|
||||
requiresMax: true,
|
||||
requiresHosted: true,
|
||||
selfHostedOverride: isInboxEnabled,
|
||||
showWhenLocked: true,
|
||||
},
|
||||
{
|
||||
id: 'credential-sets',
|
||||
label: 'Email Polling',
|
||||
icon: Mail,
|
||||
section: 'system',
|
||||
requiresTeam: true,
|
||||
requiresHosted: true,
|
||||
selfHostedOverride: isCredentialSetsEnabled,
|
||||
},
|
||||
|
||||
+29
-13
@@ -17,6 +17,8 @@ import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-to
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
|
||||
import { useAllowedMcpDomains, useCreateMcpServer } from '@/hooks/queries/mcp'
|
||||
import {
|
||||
useAddWorkflowMcpTool,
|
||||
useDeleteWorkflowMcpTool,
|
||||
@@ -26,7 +28,7 @@ import {
|
||||
type WorkflowMcpServer,
|
||||
type WorkflowMcpTool,
|
||||
} from '@/hooks/queries/workflow-mcp-servers'
|
||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -100,7 +102,11 @@ export function McpDeploy({
|
||||
}: McpDeployProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { navigateToSettings } = useSettingsNavigation()
|
||||
const [showMcpModal, setShowMcpModal] = useState(false)
|
||||
|
||||
const createMcpServer = useCreateMcpServer()
|
||||
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
|
||||
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||
|
||||
const { data: servers = [], isLoading: isLoadingServers } = useWorkflowMcpServers(workspaceId)
|
||||
const addToolMutation = useAddWorkflowMcpTool()
|
||||
@@ -464,17 +470,27 @@ export function McpDeploy({
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
Create an MCP Server in Settings → MCP Servers first.
|
||||
</p>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={() => navigateToSettings({ section: 'workflow-mcp-servers' })}
|
||||
>
|
||||
Create MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
Create an MCP Server in Settings → MCP Servers first.
|
||||
</p>
|
||||
<Button variant='tertiary' onClick={() => setShowMcpModal(true)}>
|
||||
Create MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
<McpServerFormModal
|
||||
open={showMcpModal}
|
||||
onOpenChange={setShowMcpModal}
|
||||
mode='add'
|
||||
onSubmit={async (config) => {
|
||||
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
|
||||
}}
|
||||
workspaceId={workspaceId}
|
||||
availableEnvVars={availableEnvVars}
|
||||
allowedMcpDomains={allowedMcpDomains}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Check } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
getProviderIdFromServiceId,
|
||||
OAUTH_PROVIDERS,
|
||||
type OAuthProvider,
|
||||
parseProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { getScopeDescription } from '@/lib/oauth/utils'
|
||||
import { useCreateCredentialDraft } from '@/hooks/queries/credentials'
|
||||
|
||||
const logger = createLogger('ConnectCredentialModal')
|
||||
|
||||
export interface ConnectCredentialModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
provider: OAuthProvider
|
||||
serviceId: string
|
||||
workspaceId: string
|
||||
workflowId: string
|
||||
/** Number of existing credentials for this provider — used to detect a successful new connection. */
|
||||
credentialCount: number
|
||||
}
|
||||
|
||||
export function ConnectCredentialModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
provider,
|
||||
serviceId,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
credentialCount,
|
||||
}: ConnectCredentialModalProps) {
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const createDraft = useCreateCredentialDraft()
|
||||
|
||||
const { providerName, ProviderIcon } = useMemo(() => {
|
||||
const { baseProvider } = parseProvider(provider)
|
||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||
let name = baseProviderConfig?.name || provider
|
||||
let Icon = baseProviderConfig?.icon || (() => null)
|
||||
if (baseProviderConfig) {
|
||||
for (const [key, service] of Object.entries(baseProviderConfig.services)) {
|
||||
if (key === serviceId || service.providerId === provider) {
|
||||
name = service.name
|
||||
Icon = service.icon
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return { providerName: name, ProviderIcon: Icon }
|
||||
}, [provider, serviceId])
|
||||
|
||||
const providerId = getProviderIdFromServiceId(serviceId)
|
||||
|
||||
const displayScopes = useMemo(
|
||||
() =>
|
||||
getCanonicalScopesForProvider(providerId).filter(
|
||||
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
|
||||
),
|
||||
[providerId]
|
||||
)
|
||||
|
||||
const handleClose = () => {
|
||||
setDisplayName('')
|
||||
setError(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleConnect = async () => {
|
||||
const trimmedName = displayName.trim()
|
||||
if (!trimmedName) {
|
||||
setError('Display name is required.')
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await createDraft.mutateAsync({ workspaceId, providerId, displayName: trimmedName })
|
||||
|
||||
writeOAuthReturnContext({
|
||||
origin: 'workflow',
|
||||
workflowId,
|
||||
displayName: trimmedName,
|
||||
providerId,
|
||||
preCount: credentialCount,
|
||||
workspaceId,
|
||||
requestedAt: Date.now(),
|
||||
})
|
||||
|
||||
if (providerId === 'trello') {
|
||||
window.location.href = '/api/auth/trello/authorize'
|
||||
return
|
||||
}
|
||||
|
||||
if (providerId === 'shopify') {
|
||||
const returnUrl = encodeURIComponent(window.location.href)
|
||||
window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
|
||||
return
|
||||
}
|
||||
|
||||
await client.oauth2.link({ providerId, callbackURL: window.location.href })
|
||||
handleClose()
|
||||
} catch (err) {
|
||||
logger.error('Failed to initiate OAuth connection', { error: err })
|
||||
setError('Failed to connect. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = createDraft.isPending
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>Connect {providerName}</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<div className='flex h-[40px] w-[40px] flex-shrink-0 items-center justify-center rounded-[8px] bg-[var(--surface-5)]'>
|
||||
<ProviderIcon className='h-[18px] w-[18px]' />
|
||||
</div>
|
||||
<div>
|
||||
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Connect your {providerName} account
|
||||
</p>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Grant access to use {providerName} in your workflow
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{displayScopes.length > 0 && (
|
||||
<div className='rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)]'>
|
||||
<div className='border-[var(--border-1)] border-b px-[14px] py-[10px]'>
|
||||
<h4 className='font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
Permissions requested
|
||||
</h4>
|
||||
</div>
|
||||
<ul className='max-h-[200px] space-y-[10px] overflow-y-auto px-[14px] py-[12px]'>
|
||||
{displayScopes.map((scope) => (
|
||||
<li key={scope} className='flex items-start gap-[10px]'>
|
||||
<div className='mt-[2px] flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
|
||||
<Check className='h-[10px] w-[10px] text-[var(--text-primary)]' />
|
||||
</div>
|
||||
<div className='flex flex-1 items-center gap-[8px] text-[12px] text-[var(--text-primary)]'>
|
||||
<span>{getScopeDescription(scope)}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
Display name <span className='text-[var(--text-muted)]'>*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !isPending) void handleConnect()
|
||||
}}
|
||||
placeholder={`My ${providerName} account`}
|
||||
autoComplete='off'
|
||||
data-lpignore='true'
|
||||
className='mt-[6px]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={handleClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleConnect}
|
||||
disabled={!displayName.trim() || isPending}
|
||||
>
|
||||
{isPending ? 'Connecting...' : 'Connect'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -153,7 +153,7 @@ export function OAuthRequiredModal({
|
||||
Permissions requested
|
||||
</h4>
|
||||
</div>
|
||||
<ul className='max-h-[330px] space-y-[10px] overflow-y-auto px-[14px] py-[12px]'>
|
||||
<ul className='max-h-[200px] space-y-[10px] overflow-y-auto px-[14px] py-[12px]'>
|
||||
{displayScopes.map((scope) => (
|
||||
<li key={scope} className='flex items-start gap-[10px]'>
|
||||
<div className='mt-[2px] flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
|
||||
|
||||
+23
-49
@@ -1,13 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createElement, useCallback, useMemo, useState } from 'react'
|
||||
import { ExternalLink, Users } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button, Combobox } from '@/components/emcn/components'
|
||||
import { getSubscriptionAccessState } from '@/lib/billing/client'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
|
||||
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
getProviderIdFromServiceId,
|
||||
@@ -16,17 +15,18 @@ import {
|
||||
parseProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
|
||||
import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { CREDENTIAL_SET } from '@/executor/constants'
|
||||
import { useCredentialSets } from '@/hooks/queries/credential-sets'
|
||||
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
|
||||
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
|
||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
@@ -50,6 +50,7 @@ export function CredentialSelector({
|
||||
}: CredentialSelectorProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
const [showConnectModal, setShowConnectModal] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [editingValue, setEditingValue] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
@@ -116,36 +117,11 @@ export function CredentialSelector({
|
||||
[credentialSets, selectedCredentialSetId]
|
||||
)
|
||||
|
||||
const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
|
||||
setInaccessibleCredentialName(null)
|
||||
return
|
||||
}
|
||||
|
||||
setInaccessibleCredentialName(null)
|
||||
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
|
||||
)
|
||||
if (!response.ok || cancelled) return
|
||||
const data = await response.json()
|
||||
if (!cancelled && data.credential?.displayName) {
|
||||
setInaccessibleCredentialName(data.credential.displayName)
|
||||
}
|
||||
} catch {
|
||||
// Ignore fetch errors
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
|
||||
const { data: inaccessibleCredential } = useWorkspaceCredential(
|
||||
selectedId || undefined,
|
||||
Boolean(selectedId) && !selectedCredential && !credentialsLoading && Boolean(workspaceId)
|
||||
)
|
||||
const inaccessibleCredentialName = inaccessibleCredential?.displayName ?? null
|
||||
|
||||
const resolvedLabel = useMemo(() => {
|
||||
if (selectedCredentialSet) return selectedCredentialSet.name
|
||||
@@ -157,7 +133,6 @@ export function CredentialSelector({
|
||||
const displayValue = isEditing ? editingValue : resolvedLabel
|
||||
|
||||
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
|
||||
const { navigateToSettings } = useSettingsNavigation()
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
@@ -199,21 +174,8 @@ export function CredentialSelector({
|
||||
)
|
||||
|
||||
const handleAddCredential = useCallback(() => {
|
||||
writePendingCredentialCreateRequest({
|
||||
workspaceId,
|
||||
type: 'oauth',
|
||||
providerId: effectiveProviderId,
|
||||
displayName: '',
|
||||
serviceId,
|
||||
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
|
||||
requestedAt: Date.now(),
|
||||
returnOrigin: activeWorkflowId
|
||||
? { type: 'workflow', workflowId: activeWorkflowId }
|
||||
: undefined,
|
||||
})
|
||||
|
||||
navigateToSettings({ section: 'integrations' })
|
||||
}, [workspaceId, effectiveProviderId, serviceId, activeWorkflowId])
|
||||
setShowConnectModal(true)
|
||||
}, [])
|
||||
|
||||
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
|
||||
const { baseProvider } = parseProvider(providerName)
|
||||
@@ -403,6 +365,18 @@ export function CredentialSelector({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConnectModal && (
|
||||
<ConnectCredentialModal
|
||||
isOpen={showConnectModal}
|
||||
onClose={() => setShowConnectModal(false)}
|
||||
provider={provider}
|
||||
serviceId={serviceId}
|
||||
workspaceId={workspaceId}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialCount={credentials.length}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
|
||||
+29
-48
@@ -1,8 +1,9 @@
|
||||
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
'use client'
|
||||
|
||||
import { createElement, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button, Combobox } from '@/components/emcn/components'
|
||||
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
getProviderIdFromServiceId,
|
||||
@@ -13,10 +14,11 @@ import {
|
||||
parseProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
|
||||
import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
|
||||
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
|
||||
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
|
||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const getProviderIcon = (providerName: OAuthProvider) => {
|
||||
@@ -71,11 +73,13 @@ export function ToolCredentialSelector({
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
const onChangeRef = useRef(onChange)
|
||||
onChangeRef.current = onChange
|
||||
const [showConnectModal, setShowConnectModal] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [editingInputValue, setEditingInputValue] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const { navigateToSettings } = useSettingsNavigation()
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry()
|
||||
const effectiveWorkflowId =
|
||||
activeWorkflowId && workflows[activeWorkflowId] ? activeWorkflowId : undefined
|
||||
|
||||
const selectedId = value || ''
|
||||
const effectiveLabel = label || `Select ${getProviderName(provider)} account`
|
||||
@@ -89,7 +93,7 @@ export function ToolCredentialSelector({
|
||||
} = useOAuthCredentials(effectiveProviderId, {
|
||||
enabled: Boolean(effectiveProviderId),
|
||||
workspaceId,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
workflowId: effectiveWorkflowId,
|
||||
})
|
||||
|
||||
const selectedCredential = useMemo(
|
||||
@@ -97,36 +101,11 @@ export function ToolCredentialSelector({
|
||||
[credentials, selectedId]
|
||||
)
|
||||
|
||||
const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
|
||||
setInaccessibleCredentialName(null)
|
||||
return
|
||||
}
|
||||
|
||||
setInaccessibleCredentialName(null)
|
||||
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
|
||||
)
|
||||
if (!response.ok || cancelled) return
|
||||
const data = await response.json()
|
||||
if (!cancelled && data.credential?.displayName) {
|
||||
setInaccessibleCredentialName(data.credential.displayName)
|
||||
}
|
||||
} catch {
|
||||
// Ignore fetch errors
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
|
||||
const { data: inaccessibleCredential } = useWorkspaceCredential(
|
||||
selectedId || undefined,
|
||||
Boolean(selectedId) && !selectedCredential && !credentialsLoading && Boolean(workspaceId)
|
||||
)
|
||||
const inaccessibleCredentialName = inaccessibleCredential?.displayName ?? null
|
||||
|
||||
const resolvedLabel = useMemo(() => {
|
||||
if (selectedCredential) return selectedCredential.name
|
||||
@@ -164,18 +143,8 @@ export function ToolCredentialSelector({
|
||||
)
|
||||
|
||||
const handleAddCredential = useCallback(() => {
|
||||
writePendingCredentialCreateRequest({
|
||||
workspaceId,
|
||||
type: 'oauth',
|
||||
providerId: effectiveProviderId,
|
||||
displayName: '',
|
||||
serviceId,
|
||||
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
|
||||
requestedAt: Date.now(),
|
||||
})
|
||||
|
||||
navigateToSettings({ section: 'integrations' })
|
||||
}, [workspaceId, effectiveProviderId, serviceId])
|
||||
setShowConnectModal(true)
|
||||
}, [])
|
||||
|
||||
const comboboxOptions = useMemo(() => {
|
||||
const options = credentials.map((cred) => ({
|
||||
@@ -261,6 +230,18 @@ export function ToolCredentialSelector({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConnectModal && (
|
||||
<ConnectCredentialModal
|
||||
isOpen={showConnectModal}
|
||||
onClose={() => setShowConnectModal(false)}
|
||||
provider={provider}
|
||||
serviceId={serviceId}
|
||||
workspaceId={workspaceId}
|
||||
workflowId={effectiveWorkflowId || ''}
|
||||
credentialCount={credentials.length}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
|
||||
+22
-19
@@ -27,6 +27,7 @@ import type { McpToolSchema } from '@/lib/mcp/types'
|
||||
import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService } from '@/lib/oauth'
|
||||
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
|
||||
import {
|
||||
LongInput,
|
||||
ShortInput,
|
||||
@@ -48,6 +49,7 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
|
||||
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
|
||||
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
|
||||
import {
|
||||
type CustomTool as CustomToolDefinition,
|
||||
@@ -55,12 +57,15 @@ import {
|
||||
} from '@/hooks/queries/custom-tools'
|
||||
import { useDeploymentInfo, useDeployWorkflow } from '@/hooks/queries/deployments'
|
||||
import {
|
||||
useAllowedMcpDomains,
|
||||
useCreateMcpServer,
|
||||
useForceRefreshMcpTools,
|
||||
useMcpServers,
|
||||
useMcpToolsEvents,
|
||||
useStoredMcpTools,
|
||||
} from '@/hooks/queries/mcp'
|
||||
import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
@@ -330,24 +335,6 @@ function resolveCustomToolFromReference(
|
||||
* These are distinguished from third-party integrations for categorization
|
||||
* in the tool selection dropdown.
|
||||
*/
|
||||
const BUILT_IN_TOOL_TYPES = new Set([
|
||||
'api',
|
||||
'file',
|
||||
'function',
|
||||
'knowledge',
|
||||
'search',
|
||||
'thinking',
|
||||
'image_generator',
|
||||
'video_generator',
|
||||
'vision',
|
||||
'translate',
|
||||
'tts',
|
||||
'stt',
|
||||
'memory',
|
||||
'table',
|
||||
'webhook_request',
|
||||
'workflow',
|
||||
])
|
||||
|
||||
/**
|
||||
* Checks if a block supports multiple operations.
|
||||
@@ -469,6 +456,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [customToolModalOpen, setCustomToolModalOpen] = useState(false)
|
||||
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||
const [editingToolIndex, setEditingToolIndex] = useState<number | null>(null)
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
@@ -507,6 +495,9 @@ export const ToolInput = memo(function ToolInput({
|
||||
const forceRefreshMcpTools = useForceRefreshMcpTools()
|
||||
useMcpToolsEvents(workspaceId)
|
||||
const { navigateToSettings } = useSettingsNavigation()
|
||||
const createMcpServer = useCreateMcpServer()
|
||||
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
|
||||
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||
const mcpDataLoading = mcpLoading || mcpServersLoading
|
||||
|
||||
const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false })
|
||||
@@ -1379,7 +1370,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
icon: McpIcon,
|
||||
onSelect: () => {
|
||||
setOpen(false)
|
||||
navigateToSettings({ section: 'mcp' })
|
||||
setMcpModalOpen(true)
|
||||
},
|
||||
disabled: isPreview,
|
||||
})
|
||||
@@ -2095,6 +2086,18 @@ export const ToolInput = memo(function ToolInput({
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<McpServerFormModal
|
||||
open={mcpModalOpen}
|
||||
onOpenChange={setMcpModalOpen}
|
||||
mode='add'
|
||||
onSubmit={async (config) => {
|
||||
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
|
||||
}}
|
||||
workspaceId={workspaceId}
|
||||
availableEnvVars={availableEnvVars}
|
||||
allowedMcpDomains={allowedMcpDomains}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
+318
-17
@@ -1,15 +1,19 @@
|
||||
import { Folder } from 'lucide-react'
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { Folder, MoreHorizontal, Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Blimp,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
|
||||
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import type { FolderTreeNode } from '@/stores/folders/types'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
@@ -17,19 +21,123 @@ import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
interface CollapsedSidebarMenuProps {
|
||||
icon: React.ReactNode
|
||||
hover: ReturnType<typeof useHoverMenu>
|
||||
onClick?: () => void
|
||||
ariaLabel?: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
primaryAction?: {
|
||||
label: string
|
||||
onSelect: () => void
|
||||
}
|
||||
}
|
||||
|
||||
interface CollapsedTaskFlyoutItemProps {
|
||||
task: { id: string; href: string; name: string; isActive?: boolean; isUnread?: boolean }
|
||||
isCurrentRoute: boolean
|
||||
isEditing?: boolean
|
||||
editValue?: string
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>
|
||||
isRenaming?: boolean
|
||||
onEditValueChange?: (value: string) => void
|
||||
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
onEditBlur?: () => void
|
||||
onContextMenu?: (e: ReactMouseEvent, taskId: string) => void
|
||||
onMorePointerDown?: () => void
|
||||
onMoreClick?: (e: ReactMouseEvent<HTMLButtonElement>, taskId: string) => void
|
||||
}
|
||||
|
||||
interface CollapsedWorkflowFlyoutItemProps {
|
||||
workflow: WorkflowMetadata
|
||||
href: string
|
||||
isCurrentRoute?: boolean
|
||||
isEditing?: boolean
|
||||
editValue?: string
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>
|
||||
isRenaming?: boolean
|
||||
onEditValueChange?: (value: string) => void
|
||||
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
onEditBlur?: () => void
|
||||
onContextMenu?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
|
||||
onMorePointerDown?: () => void
|
||||
onMoreClick?: (e: ReactMouseEvent<HTMLButtonElement>, workflow: WorkflowMetadata) => void
|
||||
}
|
||||
|
||||
const EDIT_ROW_CLASS =
|
||||
'mx-[2px] flex min-h-[30px] min-w-0 cursor-default select-none items-center gap-[8px] rounded-[5px] bg-[var(--surface-active)] px-[8px] py-[5px] font-medium text-[12px] text-[var(--text-body)]'
|
||||
|
||||
function FlyoutMoreButton({
|
||||
ariaLabel,
|
||||
onPointerDown,
|
||||
onClick,
|
||||
}: {
|
||||
ariaLabel: string
|
||||
onPointerDown?: () => void
|
||||
onClick: (e: ReactMouseEvent<HTMLButtonElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
aria-label={ariaLabel}
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={onClick}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-[8px] z-10 flex h-[18px] w-[18px] items-center justify-center rounded-[4px] opacity-0 transition-opacity hover:bg-[var(--surface-7)] focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100'
|
||||
>
|
||||
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskStatusIcon({
|
||||
isActive,
|
||||
isUnread,
|
||||
hideStatusOnHover = false,
|
||||
}: {
|
||||
isActive?: boolean
|
||||
isUnread?: boolean
|
||||
hideStatusOnHover?: boolean
|
||||
}) {
|
||||
return (
|
||||
<span className='relative flex-shrink-0'>
|
||||
<Blimp className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
{isActive && (
|
||||
<span
|
||||
className={cn(
|
||||
'-right-[1px] -bottom-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-amber-400',
|
||||
hideStatusOnHover && 'group-hover:hidden'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!isActive && isUnread && (
|
||||
<span
|
||||
className={cn(
|
||||
'-right-[1px] -bottom-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-[#33C482]',
|
||||
hideStatusOnHover && 'group-hover:hidden'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkflowColorSwatch({ color }: { color: string }) {
|
||||
return (
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: `${color}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CollapsedSidebarMenu({
|
||||
icon,
|
||||
hover,
|
||||
onClick,
|
||||
ariaLabel,
|
||||
children,
|
||||
className,
|
||||
primaryAction,
|
||||
}: CollapsedSidebarMenuProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col px-[8px]', className)}>
|
||||
@@ -47,13 +155,21 @@ export function CollapsedSidebarMenu({
|
||||
type='button'
|
||||
aria-label={ariaLabel}
|
||||
className='mx-[2px] flex h-[30px] items-center rounded-[8px] px-[8px] hover:bg-[var(--surface-active)]'
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent side='right' align='start' sideOffset={8} {...hover.contentProps}>
|
||||
{primaryAction && (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={primaryAction.onSelect}>
|
||||
<Plus className='h-[14px] w-[14px]' />
|
||||
{primaryAction.label}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -61,14 +177,185 @@ export function CollapsedSidebarMenu({
|
||||
)
|
||||
}
|
||||
|
||||
export function CollapsedTaskFlyoutItem({
|
||||
task,
|
||||
isCurrentRoute,
|
||||
isEditing = false,
|
||||
editValue,
|
||||
inputRef,
|
||||
isRenaming = false,
|
||||
onEditValueChange,
|
||||
onEditKeyDown,
|
||||
onEditBlur,
|
||||
onContextMenu,
|
||||
onMorePointerDown,
|
||||
onMoreClick,
|
||||
}: CollapsedTaskFlyoutItemProps) {
|
||||
const showActions = task.id !== 'new' && onMoreClick
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className={EDIT_ROW_CLASS}>
|
||||
<TaskStatusIcon isActive={task.isActive} isUnread={task.isUnread} />
|
||||
<input
|
||||
aria-label={`Rename task ${task.name}`}
|
||||
ref={inputRef}
|
||||
value={editValue ?? task.name}
|
||||
onChange={(e) => onEditValueChange?.(e.target.value)}
|
||||
onKeyDown={onEditKeyDown}
|
||||
onBlur={onEditBlur}
|
||||
className='w-full min-w-0 border-0 bg-transparent p-0 font-medium text-[12px] text-[var(--text-body)] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
maxLength={100}
|
||||
disabled={isRenaming}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='group relative mx-[2px]'>
|
||||
<Link
|
||||
href={task.href}
|
||||
className={cn(
|
||||
'flex min-h-[30px] min-w-0 items-center rounded-[5px] px-[8px] py-[5px] pr-[30px] font-medium text-[12px] text-[var(--text-body)] hover:bg-[var(--surface-active)] group-focus-within:bg-[var(--surface-active)] group-hover:bg-[var(--surface-active)]',
|
||||
isCurrentRoute && 'bg-[var(--surface-active)]'
|
||||
)}
|
||||
onContextMenu={
|
||||
task.id !== 'new' && onContextMenu ? (e) => onContextMenu(e, task.id) : undefined
|
||||
}
|
||||
>
|
||||
<ConversationListItem
|
||||
title={task.name}
|
||||
isActive={!!task.isActive}
|
||||
isUnread={!!task.isUnread}
|
||||
statusIndicatorClassName={!isCurrentRoute ? 'group-hover:hidden' : undefined}
|
||||
/>
|
||||
</Link>
|
||||
{showActions && (
|
||||
<FlyoutMoreButton
|
||||
ariaLabel='Task options'
|
||||
onPointerDown={onMorePointerDown}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onMoreClick?.(e, task.id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CollapsedWorkflowFlyoutItem({
|
||||
workflow,
|
||||
href,
|
||||
isCurrentRoute = false,
|
||||
isEditing = false,
|
||||
editValue,
|
||||
inputRef,
|
||||
isRenaming = false,
|
||||
onEditValueChange,
|
||||
onEditKeyDown,
|
||||
onEditBlur,
|
||||
onContextMenu,
|
||||
onMorePointerDown,
|
||||
onMoreClick,
|
||||
}: CollapsedWorkflowFlyoutItemProps) {
|
||||
const showActions = !!onMoreClick
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className={EDIT_ROW_CLASS}>
|
||||
<WorkflowColorSwatch color={workflow.color} />
|
||||
<input
|
||||
aria-label={`Rename workflow ${workflow.name}`}
|
||||
ref={inputRef}
|
||||
value={editValue ?? workflow.name}
|
||||
onChange={(e) => onEditValueChange?.(e.target.value)}
|
||||
onKeyDown={onEditKeyDown}
|
||||
onBlur={onEditBlur}
|
||||
className='w-full min-w-0 border-0 bg-transparent p-0 font-medium text-[12px] text-[var(--text-body)] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
maxLength={100}
|
||||
disabled={isRenaming}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='group relative mx-[2px]'>
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex min-h-[30px] min-w-0 items-center gap-[8px] rounded-[5px] px-[8px] py-[5px] pr-[30px] font-medium text-[12px] text-[var(--text-body)] hover:bg-[var(--surface-active)] group-focus-within:bg-[var(--surface-active)] group-hover:bg-[var(--surface-active)]',
|
||||
isCurrentRoute && 'bg-[var(--surface-active)]'
|
||||
)}
|
||||
onContextMenu={onContextMenu ? (e) => onContextMenu(e, workflow) : undefined}
|
||||
>
|
||||
<WorkflowColorSwatch color={workflow.color} />
|
||||
<span className='min-w-0 flex-1 truncate'>{workflow.name}</span>
|
||||
</Link>
|
||||
{showActions && (
|
||||
<FlyoutMoreButton
|
||||
ariaLabel='Workflow options'
|
||||
onPointerDown={onMorePointerDown}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onMoreClick?.(e, workflow)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CollapsedFolderItems({
|
||||
nodes,
|
||||
workflowsByFolder,
|
||||
workspaceId,
|
||||
currentWorkflowId,
|
||||
editingWorkflowId,
|
||||
editingValue,
|
||||
editInputRef,
|
||||
isRenamingWorkflow,
|
||||
onEditValueChange,
|
||||
onEditKeyDown,
|
||||
onEditBlur,
|
||||
onWorkflowContextMenu,
|
||||
onWorkflowMorePointerDown,
|
||||
onWorkflowMoreClick,
|
||||
}: {
|
||||
nodes: FolderTreeNode[]
|
||||
workflowsByFolder: Record<string, WorkflowMetadata[]>
|
||||
workspaceId: string
|
||||
currentWorkflowId?: string
|
||||
editingWorkflowId?: string | null
|
||||
editingValue?: string
|
||||
editInputRef?: React.RefObject<HTMLInputElement | null>
|
||||
isRenamingWorkflow?: boolean
|
||||
onEditValueChange?: (value: string) => void
|
||||
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
onEditBlur?: () => void
|
||||
onWorkflowContextMenu?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
|
||||
onWorkflowMorePointerDown?: () => void
|
||||
onWorkflowMoreClick?: (e: ReactMouseEvent<HTMLButtonElement>, workflow: WorkflowMetadata) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -96,21 +383,35 @@ export function CollapsedFolderItems({
|
||||
nodes={folder.children}
|
||||
workflowsByFolder={workflowsByFolder}
|
||||
workspaceId={workspaceId}
|
||||
currentWorkflowId={currentWorkflowId}
|
||||
editingWorkflowId={editingWorkflowId}
|
||||
editingValue={editingValue}
|
||||
editInputRef={editInputRef}
|
||||
isRenamingWorkflow={isRenamingWorkflow}
|
||||
onEditValueChange={onEditValueChange}
|
||||
onEditKeyDown={onEditKeyDown}
|
||||
onEditBlur={onEditBlur}
|
||||
onWorkflowContextMenu={onWorkflowContextMenu}
|
||||
onWorkflowMorePointerDown={onWorkflowMorePointerDown}
|
||||
onWorkflowMoreClick={onWorkflowMoreClick}
|
||||
/>
|
||||
{folderWorkflows.map((workflow) => (
|
||||
<DropdownMenuItem key={workflow.id} asChild>
|
||||
<Link href={`/workspace/${workspaceId}/w/${workflow.id}`}>
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: workflow.color,
|
||||
borderColor: `${workflow.color}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
<span className='truncate'>{workflow.name}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<CollapsedWorkflowFlyoutItem
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
href={`/workspace/${workspaceId}/w/${workflow.id}`}
|
||||
isCurrentRoute={workflow.id === currentWorkflowId}
|
||||
isEditing={workflow.id === editingWorkflowId}
|
||||
editValue={editingValue}
|
||||
inputRef={editInputRef}
|
||||
isRenaming={isRenamingWorkflow}
|
||||
onEditValueChange={onEditValueChange}
|
||||
onEditKeyDown={onEditKeyDown}
|
||||
onEditBlur={onEditBlur}
|
||||
onContextMenu={onWorkflowContextMenu}
|
||||
onMorePointerDown={onWorkflowMorePointerDown}
|
||||
onMoreClick={onWorkflowMoreClick}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export {
|
||||
CollapsedFolderItems,
|
||||
CollapsedSidebarMenu,
|
||||
CollapsedTaskFlyoutItem,
|
||||
CollapsedWorkflowFlyoutItem,
|
||||
} from './collapsed-sidebar-menu/collapsed-sidebar-menu'
|
||||
export { HelpModal } from './help-modal/help-modal'
|
||||
export { NavItemContextMenu } from './nav-item-context-menu'
|
||||
|
||||
+38
-31
@@ -27,12 +27,12 @@ const SKELETON_SECTIONS = [3, 2, 2] as const
|
||||
|
||||
interface SettingsSidebarProps {
|
||||
isCollapsed?: boolean
|
||||
showCollapsedContent?: boolean
|
||||
showCollapsedTooltips?: boolean
|
||||
}
|
||||
|
||||
export function SettingsSidebar({
|
||||
isCollapsed = false,
|
||||
showCollapsedContent = false,
|
||||
showCollapsedTooltips = false,
|
||||
}: SettingsSidebarProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -74,55 +74,62 @@ export function SettingsSidebar({
|
||||
}, [userId, ssoProvidersData?.providers, isLoadingSSO])
|
||||
|
||||
const navigationItems = useMemo(() => {
|
||||
return allNavigationItems.flatMap((item) => {
|
||||
return allNavigationItems.filter((item) => {
|
||||
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
|
||||
return []
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.id === 'template-profile') {
|
||||
return []
|
||||
return false
|
||||
}
|
||||
if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) {
|
||||
return []
|
||||
return false
|
||||
}
|
||||
if (item.id === 'mcp' && permissionConfig.disableMcpTools) {
|
||||
return []
|
||||
return false
|
||||
}
|
||||
if (item.id === 'custom-tools' && permissionConfig.disableCustomTools) {
|
||||
return []
|
||||
return false
|
||||
}
|
||||
if (item.id === 'skills' && permissionConfig.disableSkills) {
|
||||
return []
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.selfHostedOverride && !isHosted) {
|
||||
if (item.id === 'sso') {
|
||||
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
|
||||
return !hasProviders || isSSOProviderOwner === true ? [{ ...item, disabled: false }] : []
|
||||
return !hasProviders || isSSOProviderOwner === true
|
||||
}
|
||||
return [{ ...item, disabled: false }]
|
||||
return true
|
||||
}
|
||||
|
||||
if (item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.requiresEnterprise && (!hasEnterprisePlan || !isOrgAdminOrOwner)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.requiresMax && !subscriptionAccess.hasUsableMaxAccess && !item.showWhenLocked) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.requiresHosted && !isHosted) {
|
||||
return []
|
||||
return false
|
||||
}
|
||||
|
||||
const superUserModeEnabled = generalSettings?.superUserModeEnabled ?? false
|
||||
const effectiveSuperUser = isSuperUser && superUserModeEnabled
|
||||
if (item.requiresSuperUser && !effectiveSuperUser) {
|
||||
return []
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.requiresAdminRole && !isSuperUser) {
|
||||
return []
|
||||
return false
|
||||
}
|
||||
|
||||
const disabled =
|
||||
(item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) ||
|
||||
(item.requiresEnterprise && (!hasEnterprisePlan || !isOrgAdminOrOwner)) ||
|
||||
(item.requiresMax && !subscriptionAccess.hasUsableMaxAccess)
|
||||
|
||||
return [{ ...item, disabled }]
|
||||
return true
|
||||
})
|
||||
}, [
|
||||
hasTeamPlan,
|
||||
@@ -192,7 +199,7 @@ export function SettingsSidebar({
|
||||
<span className='truncate font-base text-[var(--text-body)]'>Back</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
{showCollapsedContent && (
|
||||
{showCollapsedTooltips && (
|
||||
<Tooltip.Content side='right'>
|
||||
<p>Back</p>
|
||||
</Tooltip.Content>
|
||||
@@ -250,20 +257,22 @@ export function SettingsSidebar({
|
||||
{sectionItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = activeSection === item.id
|
||||
const disabled = Boolean(item.disabled)
|
||||
const isLocked = item.requiresMax && !subscriptionAccess.hasUsableMaxAccess
|
||||
const itemClassName = cn(
|
||||
'group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px]',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'hover:bg-[var(--surface-active)]',
|
||||
active && !disabled && 'bg-[var(--surface-active)]'
|
||||
'group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]',
|
||||
active && 'bg-[var(--surface-active)]'
|
||||
)
|
||||
const content = (
|
||||
<>
|
||||
<Icon className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate font-base text-[var(--text-body)]'>
|
||||
<span className='min-w-0 truncate font-base text-[var(--text-body)]'>
|
||||
{item.label}
|
||||
</span>
|
||||
{isLocked && (
|
||||
<span className='ml-auto shrink-0 rounded-[3px] bg-[var(--surface-5)] px-[4px] py-[1px] font-medium text-[9px] text-[var(--text-icon)] uppercase tracking-wide'>
|
||||
Max
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -280,11 +289,9 @@ export function SettingsSidebar({
|
||||
<button
|
||||
type='button'
|
||||
className={itemClassName}
|
||||
disabled={disabled}
|
||||
onMouseEnter={() => handlePrefetch(item.id)}
|
||||
onFocus={() => handlePrefetch(item.id)}
|
||||
onClick={() =>
|
||||
!disabled &&
|
||||
router.replace(getSettingsHref({ section: item.id as SettingsSection }), {
|
||||
scroll: false,
|
||||
})
|
||||
@@ -297,7 +304,7 @@ export function SettingsSidebar({
|
||||
return (
|
||||
<Tooltip.Root key={`${item.id}-${isCollapsed}`}>
|
||||
<Tooltip.Trigger asChild>{element}</Tooltip.Trigger>
|
||||
{showCollapsedContent && (
|
||||
{showCollapsedTooltips && (
|
||||
<Tooltip.Content side='right'>
|
||||
<p>{item.label}</p>
|
||||
</Tooltip.Content>
|
||||
|
||||
+60
-12
@@ -15,9 +15,11 @@ import {
|
||||
import {
|
||||
Check,
|
||||
Duplicate,
|
||||
Eye,
|
||||
FolderPlus,
|
||||
Lock,
|
||||
LogOut,
|
||||
Mail,
|
||||
Palette,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -230,6 +232,8 @@ interface ContextMenuProps {
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
onClose: () => void
|
||||
onOpenInNewTab?: () => void
|
||||
onMarkAsRead?: () => void
|
||||
onMarkAsUnread?: () => void
|
||||
onRename?: () => void
|
||||
onCreate?: () => void
|
||||
onCreateFolder?: () => void
|
||||
@@ -239,6 +243,8 @@ interface ContextMenuProps {
|
||||
onColorChange?: (color: string) => void
|
||||
currentColor?: string
|
||||
showOpenInNewTab?: boolean
|
||||
showMarkAsRead?: boolean
|
||||
showMarkAsUnread?: boolean
|
||||
showRename?: boolean
|
||||
showCreate?: boolean
|
||||
showCreateFolder?: boolean
|
||||
@@ -246,6 +252,8 @@ interface ContextMenuProps {
|
||||
showExport?: boolean
|
||||
showColorChange?: boolean
|
||||
disableExport?: boolean
|
||||
disableMarkAsRead?: boolean
|
||||
disableMarkAsUnread?: boolean
|
||||
disableColorChange?: boolean
|
||||
disableRename?: boolean
|
||||
disableDuplicate?: boolean
|
||||
@@ -259,6 +267,7 @@ interface ContextMenuProps {
|
||||
showLock?: boolean
|
||||
disableLock?: boolean
|
||||
isLocked?: boolean
|
||||
showDelete?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,6 +280,8 @@ export function ContextMenu({
|
||||
menuRef,
|
||||
onClose,
|
||||
onOpenInNewTab,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onRename,
|
||||
onCreate,
|
||||
onCreateFolder,
|
||||
@@ -280,6 +291,8 @@ export function ContextMenu({
|
||||
onColorChange,
|
||||
currentColor,
|
||||
showOpenInNewTab = false,
|
||||
showMarkAsRead = false,
|
||||
showMarkAsUnread = false,
|
||||
showRename = true,
|
||||
showCreate = false,
|
||||
showCreateFolder = false,
|
||||
@@ -287,6 +300,8 @@ export function ContextMenu({
|
||||
showExport = false,
|
||||
showColorChange = false,
|
||||
disableExport = false,
|
||||
disableMarkAsRead = false,
|
||||
disableMarkAsUnread = false,
|
||||
disableColorChange = false,
|
||||
disableRename = false,
|
||||
disableDuplicate = false,
|
||||
@@ -300,6 +315,7 @@ export function ContextMenu({
|
||||
showLock = false,
|
||||
disableLock = false,
|
||||
isLocked = false,
|
||||
showDelete = true,
|
||||
}: ContextMenuProps) {
|
||||
const [hexInput, setHexInput] = useState(currentColor || '#ffffff')
|
||||
|
||||
@@ -346,6 +362,7 @@ export function ContextMenu({
|
||||
}, [])
|
||||
|
||||
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
|
||||
const hasStatusSection = (showMarkAsRead && onMarkAsRead) || (showMarkAsUnread && onMarkAsUnread)
|
||||
const hasEditSection =
|
||||
(showRename && onRename) ||
|
||||
(showCreate && onCreate) ||
|
||||
@@ -387,7 +404,35 @@ export function ContextMenu({
|
||||
Open in new tab
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasNavigationSection && (hasEditSection || hasCopySection) && <DropdownMenuSeparator />}
|
||||
{hasNavigationSection && (hasStatusSection || hasEditSection || hasCopySection) && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
|
||||
{showMarkAsRead && onMarkAsRead && (
|
||||
<DropdownMenuItem
|
||||
disabled={disableMarkAsRead}
|
||||
onSelect={() => {
|
||||
onMarkAsRead()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<Eye />
|
||||
Mark as read
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showMarkAsUnread && onMarkAsUnread && (
|
||||
<DropdownMenuItem
|
||||
disabled={disableMarkAsUnread}
|
||||
onSelect={() => {
|
||||
onMarkAsUnread()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<Mail />
|
||||
Mark as unread
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasStatusSection && (hasEditSection || hasCopySection) && <DropdownMenuSeparator />}
|
||||
|
||||
{showRename && onRename && (
|
||||
<DropdownMenuItem
|
||||
@@ -478,7 +523,8 @@ export function ContextMenu({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{(hasNavigationSection || hasEditSection || hasCopySection) && <DropdownMenuSeparator />}
|
||||
{(hasNavigationSection || hasStatusSection || hasEditSection || hasCopySection) &&
|
||||
(showLeave || showDelete) && <DropdownMenuSeparator />}
|
||||
{showLeave && onLeave && (
|
||||
<DropdownMenuItem
|
||||
disabled={disableLeave}
|
||||
@@ -491,16 +537,18 @@ export function ContextMenu({
|
||||
Leave
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
disabled={disableDelete}
|
||||
onSelect={() => {
|
||||
onDelete()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<Trash />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
{showDelete && (
|
||||
<DropdownMenuItem
|
||||
disabled={disableDelete}
|
||||
onSelect={() => {
|
||||
onDelete()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<Trash />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { useAutoScroll } from './use-auto-scroll'
|
||||
export { useContextMenu } from './use-context-menu'
|
||||
export { type DropIndicator, useDragDrop } from './use-drag-drop'
|
||||
export { useFlyoutInlineRename } from './use-flyout-inline-rename'
|
||||
export { useFolderExpand } from './use-folder-expand'
|
||||
export { useFolderOperations } from './use-folder-operations'
|
||||
export { useFolderSelection } from './use-folder-selection'
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
|
||||
const logger = createLogger('useFlyoutInlineRename')
|
||||
|
||||
interface RenameTarget {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface UseFlyoutInlineRenameProps {
|
||||
itemType: string
|
||||
onSave: (id: string, name: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useFlyoutInlineRename({ itemType, onSave }: UseFlyoutInlineRenameProps) {
|
||||
const [editingTarget, setEditingTarget] = useState<RenameTarget | null>(null)
|
||||
const [value, setValue] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const cancelRequestedRef = useRef(false)
|
||||
const isSavingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (editingTarget && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.select()
|
||||
}
|
||||
}, [editingTarget])
|
||||
|
||||
const startRename = useCallback((target: RenameTarget) => {
|
||||
cancelRequestedRef.current = false
|
||||
setEditingTarget(target)
|
||||
setValue(target.name)
|
||||
}, [])
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
cancelRequestedRef.current = true
|
||||
setEditingTarget(null)
|
||||
}, [])
|
||||
|
||||
const saveRename = useCallback(async () => {
|
||||
if (cancelRequestedRef.current) {
|
||||
cancelRequestedRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!editingTarget || isSavingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim()
|
||||
if (!trimmedValue || trimmedValue === editingTarget.name) {
|
||||
setEditingTarget(null)
|
||||
return
|
||||
}
|
||||
|
||||
isSavingRef.current = true
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await onSave(editingTarget.id, trimmedValue)
|
||||
setEditingTarget(null)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to rename ${itemType}:`, {
|
||||
error,
|
||||
itemId: editingTarget.id,
|
||||
oldName: editingTarget.name,
|
||||
newName: trimmedValue,
|
||||
})
|
||||
setValue(editingTarget.name)
|
||||
} finally {
|
||||
isSavingRef.current = false
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [editingTarget, itemType, onSave, value])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
void saveRename()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelRename()
|
||||
}
|
||||
},
|
||||
[cancelRename, saveRename]
|
||||
)
|
||||
|
||||
return {
|
||||
editingId: editingTarget?.id ?? null,
|
||||
value,
|
||||
setValue,
|
||||
isSaving,
|
||||
inputRef,
|
||||
startRename,
|
||||
cancelRename,
|
||||
saveRename,
|
||||
handleKeyDown,
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ const preventAutoFocus = (e: Event) => e.preventDefault()
|
||||
export function useHoverMenu() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isLockedRef = useRef(false)
|
||||
const hoverRegionCountRef = useRef(0)
|
||||
|
||||
const cancelClose = useCallback(() => {
|
||||
if (closeTimerRef.current) {
|
||||
@@ -29,8 +31,15 @@ export function useHoverMenu() {
|
||||
}, [])
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
if (isLockedRef.current) {
|
||||
return
|
||||
}
|
||||
cancelClose()
|
||||
closeTimerRef.current = setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS)
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
if (!isLockedRef.current && hoverRegionCountRef.current === 0) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}, CLOSE_DELAY_MS)
|
||||
}, [cancelClose])
|
||||
|
||||
const open = useCallback(() => {
|
||||
@@ -39,24 +48,64 @@ export function useHoverMenu() {
|
||||
}, [cancelClose])
|
||||
|
||||
const close = useCallback(() => {
|
||||
if (isLockedRef.current) {
|
||||
return
|
||||
}
|
||||
cancelClose()
|
||||
setIsOpen(false)
|
||||
}, [cancelClose])
|
||||
|
||||
const setLocked = useCallback(
|
||||
(locked: boolean) => {
|
||||
isLockedRef.current = locked
|
||||
cancelClose()
|
||||
if (locked) {
|
||||
setIsOpen(true)
|
||||
} else if (hoverRegionCountRef.current === 0) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
},
|
||||
[cancelClose]
|
||||
)
|
||||
|
||||
const handleTriggerMouseEnter = useCallback(() => {
|
||||
hoverRegionCountRef.current += 1
|
||||
open()
|
||||
}, [open])
|
||||
|
||||
const handleTriggerMouseLeave = useCallback(() => {
|
||||
hoverRegionCountRef.current = Math.max(0, hoverRegionCountRef.current - 1)
|
||||
scheduleClose()
|
||||
}, [scheduleClose])
|
||||
|
||||
const handleContentMouseEnter = useCallback(() => {
|
||||
hoverRegionCountRef.current += 1
|
||||
cancelClose()
|
||||
}, [cancelClose])
|
||||
|
||||
const handleContentMouseLeave = useCallback(() => {
|
||||
hoverRegionCountRef.current = Math.max(0, hoverRegionCountRef.current - 1)
|
||||
scheduleClose()
|
||||
}, [scheduleClose])
|
||||
|
||||
const triggerProps = useMemo(
|
||||
() => ({ onMouseEnter: open, onMouseLeave: scheduleClose }) as const,
|
||||
[open, scheduleClose]
|
||||
() =>
|
||||
({
|
||||
onMouseEnter: handleTriggerMouseEnter,
|
||||
onMouseLeave: handleTriggerMouseLeave,
|
||||
}) as const,
|
||||
[handleTriggerMouseEnter, handleTriggerMouseLeave]
|
||||
)
|
||||
|
||||
const contentProps = useMemo(
|
||||
() =>
|
||||
({
|
||||
onMouseEnter: cancelClose,
|
||||
onMouseLeave: scheduleClose,
|
||||
onMouseEnter: handleContentMouseEnter,
|
||||
onMouseLeave: handleContentMouseLeave,
|
||||
onCloseAutoFocus: preventAutoFocus,
|
||||
}) as const,
|
||||
[cancelClose, scheduleClose]
|
||||
[handleContentMouseEnter, handleContentMouseLeave]
|
||||
)
|
||||
|
||||
return { isOpen, open, close, triggerProps, contentProps }
|
||||
return { isOpen, open, close, setLocked, triggerProps, contentProps }
|
||||
}
|
||||
|
||||
@@ -33,10 +33,10 @@ import {
|
||||
Settings,
|
||||
Sim,
|
||||
Table,
|
||||
Wordmark,
|
||||
} from '@/components/emcn/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
START_NAV_TOUR_EVENT,
|
||||
START_WORKFLOW_TOUR_EVENT,
|
||||
@@ -47,6 +47,8 @@ import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-uti
|
||||
import {
|
||||
CollapsedFolderItems,
|
||||
CollapsedSidebarMenu,
|
||||
CollapsedTaskFlyoutItem,
|
||||
CollapsedWorkflowFlyoutItem,
|
||||
HelpModal,
|
||||
NavItemContextMenu,
|
||||
SearchModal,
|
||||
@@ -58,6 +60,7 @@ import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/
|
||||
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
|
||||
import {
|
||||
useContextMenu,
|
||||
useFlyoutInlineRename,
|
||||
useFolderOperations,
|
||||
useHoverMenu,
|
||||
useSidebarResize,
|
||||
@@ -74,7 +77,14 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import { useDeleteTask, useDeleteTasks, useRenameTask, useTasks } from '@/hooks/queries/tasks'
|
||||
import {
|
||||
useDeleteTask,
|
||||
useDeleteTasks,
|
||||
useMarkTaskRead,
|
||||
useMarkTaskUnread,
|
||||
useRenameTask,
|
||||
useTasks,
|
||||
} from '@/hooks/queries/tasks'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
import { useTaskEvents } from '@/hooks/use-task-events'
|
||||
@@ -82,6 +92,7 @@ import { SIDEBAR_WIDTH } from '@/stores/constants'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useSearchModalStore } from '@/stores/modals/search/store'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('Sidebar')
|
||||
|
||||
@@ -99,7 +110,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||
isSelected,
|
||||
isActive,
|
||||
isUnread,
|
||||
showCollapsedContent,
|
||||
showCollapsedTooltips,
|
||||
onMultiSelectClick,
|
||||
onContextMenu,
|
||||
onMorePointerDown,
|
||||
@@ -110,7 +121,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||
isSelected: boolean
|
||||
isActive: boolean
|
||||
isUnread: boolean
|
||||
showCollapsedContent: boolean
|
||||
showCollapsedTooltips: boolean
|
||||
onMultiSelectClick: (taskId: string, shiftKey: boolean, metaKey: boolean) => void
|
||||
onContextMenu: (e: React.MouseEvent, taskId: string) => void
|
||||
onMorePointerDown: () => void
|
||||
@@ -171,7 +182,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||
)}
|
||||
</Link>
|
||||
</Tooltip.Trigger>
|
||||
{showCollapsedContent && (
|
||||
{showCollapsedTooltips && (
|
||||
<Tooltip.Content side='right'>
|
||||
<p>{task.name}</p>
|
||||
</Tooltip.Content>
|
||||
@@ -191,12 +202,12 @@ interface SidebarNavItemData {
|
||||
const SidebarNavItem = memo(function SidebarNavItem({
|
||||
item,
|
||||
active,
|
||||
showCollapsedContent,
|
||||
showCollapsedTooltips,
|
||||
onContextMenu,
|
||||
}: {
|
||||
item: SidebarNavItemData
|
||||
active: boolean
|
||||
showCollapsedContent: boolean
|
||||
showCollapsedTooltips: boolean
|
||||
onContextMenu?: (e: React.MouseEvent, href: string) => void
|
||||
}) {
|
||||
const Icon = item.icon
|
||||
@@ -245,7 +256,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>{element}</Tooltip.Trigger>
|
||||
{showCollapsedContent && (
|
||||
{showCollapsedTooltips && (
|
||||
<Tooltip.Content side='right'>
|
||||
<p>{item.label}</p>
|
||||
</Tooltip.Content>
|
||||
@@ -296,7 +307,8 @@ export const Sidebar = memo(function Sidebar() {
|
||||
const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
|
||||
const isOnWorkflowPage = !!workflowId
|
||||
|
||||
const [showCollapsedContent, setShowCollapsedContent] = useState(isCollapsed)
|
||||
// Delay collapsed tooltips until the width transition finishes.
|
||||
const [showCollapsedTooltips, setShowCollapsedTooltips] = useState(isCollapsed)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isCollapsed) {
|
||||
@@ -306,10 +318,10 @@ export const Sidebar = memo(function Sidebar() {
|
||||
|
||||
useEffect(() => {
|
||||
if (isCollapsed) {
|
||||
const timer = setTimeout(() => setShowCollapsedContent(true), 200)
|
||||
const timer = setTimeout(() => setShowCollapsedTooltips(true), 200)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
setShowCollapsedContent(false)
|
||||
setShowCollapsedTooltips(false)
|
||||
}, [isCollapsed])
|
||||
|
||||
const workspaceFileInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -398,6 +410,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
useFolders(workspaceId)
|
||||
const folders = useFolderStore((s) => s.folders)
|
||||
const getFolderTree = useFolderStore((s) => s.getFolderTree)
|
||||
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
|
||||
|
||||
const folderTree = useMemo(
|
||||
() => (isCollapsed && workspaceId ? getFolderTree(workspaceId) : []),
|
||||
@@ -450,7 +463,11 @@ export const Sidebar = memo(function Sidebar() {
|
||||
|
||||
const deleteTaskMutation = useDeleteTask(workspaceId)
|
||||
const deleteTasksMutation = useDeleteTasks(workspaceId)
|
||||
const markTaskReadMutation = useMarkTaskRead(workspaceId)
|
||||
const markTaskUnreadMutation = useMarkTaskUnread(workspaceId)
|
||||
const renameTaskMutation = useRenameTask(workspaceId)
|
||||
const tasksHover = useHoverMenu()
|
||||
const workflowsHover = useHoverMenu()
|
||||
|
||||
const {
|
||||
isOpen: isTaskContextMenuOpen,
|
||||
@@ -482,9 +499,11 @@ export const Sidebar = memo(function Sidebar() {
|
||||
const handleTaskContextMenu = useCallback(
|
||||
(e: React.MouseEvent, taskId: string) => {
|
||||
captureTaskSelection(taskId)
|
||||
tasksHover.setLocked(true)
|
||||
preventTaskDismiss()
|
||||
handleTaskContextMenuBase(e)
|
||||
},
|
||||
[captureTaskSelection, handleTaskContextMenuBase]
|
||||
[captureTaskSelection, handleTaskContextMenuBase, preventTaskDismiss, tasksHover]
|
||||
)
|
||||
|
||||
const handleTaskMorePointerDown = useCallback(() => {
|
||||
@@ -499,6 +518,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
closeTaskContextMenu()
|
||||
return
|
||||
}
|
||||
tasksHover.setLocked(true)
|
||||
captureTaskSelection(taskId)
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
handleTaskContextMenuBase({
|
||||
@@ -508,7 +528,84 @@ export const Sidebar = memo(function Sidebar() {
|
||||
clientY: rect.top,
|
||||
} as React.MouseEvent)
|
||||
},
|
||||
[isTaskContextMenuOpen, closeTaskContextMenu, captureTaskSelection, handleTaskContextMenuBase]
|
||||
[
|
||||
isTaskContextMenuOpen,
|
||||
closeTaskContextMenu,
|
||||
captureTaskSelection,
|
||||
handleTaskContextMenuBase,
|
||||
tasksHover,
|
||||
]
|
||||
)
|
||||
|
||||
const {
|
||||
isOpen: isCollapsedWorkflowContextMenuOpen,
|
||||
position: collapsedWorkflowContextMenuPosition,
|
||||
menuRef: collapsedWorkflowMenuRef,
|
||||
handleContextMenu: handleCollapsedWorkflowContextMenuBase,
|
||||
closeMenu: closeCollapsedWorkflowContextMenu,
|
||||
preventDismiss: preventCollapsedWorkflowDismiss,
|
||||
} = useContextMenu()
|
||||
|
||||
const collapsedWorkflowContextMenuRef = useRef<{
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
} | null>(null)
|
||||
|
||||
const captureCollapsedWorkflowSelection = useCallback(
|
||||
(workflow: { id: string; name: string }) => {
|
||||
collapsedWorkflowContextMenuRef.current = {
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name,
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleCollapsedWorkflowContextMenu = useCallback(
|
||||
(e: React.MouseEvent, workflow: { id: string; name: string }) => {
|
||||
captureCollapsedWorkflowSelection(workflow)
|
||||
workflowsHover.setLocked(true)
|
||||
preventCollapsedWorkflowDismiss()
|
||||
handleCollapsedWorkflowContextMenuBase(e)
|
||||
},
|
||||
[
|
||||
captureCollapsedWorkflowSelection,
|
||||
handleCollapsedWorkflowContextMenuBase,
|
||||
preventCollapsedWorkflowDismiss,
|
||||
workflowsHover,
|
||||
]
|
||||
)
|
||||
|
||||
const handleCollapsedWorkflowMorePointerDown = useCallback(() => {
|
||||
if (isCollapsedWorkflowContextMenuOpen) {
|
||||
preventCollapsedWorkflowDismiss()
|
||||
}
|
||||
}, [isCollapsedWorkflowContextMenuOpen, preventCollapsedWorkflowDismiss])
|
||||
|
||||
const handleCollapsedWorkflowMoreClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>, workflow: { id: string; name: string }) => {
|
||||
if (isCollapsedWorkflowContextMenuOpen) {
|
||||
closeCollapsedWorkflowContextMenu()
|
||||
return
|
||||
}
|
||||
|
||||
workflowsHover.setLocked(true)
|
||||
captureCollapsedWorkflowSelection(workflow)
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
handleCollapsedWorkflowContextMenuBase({
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
clientX: rect.right,
|
||||
clientY: rect.top,
|
||||
} as React.MouseEvent)
|
||||
},
|
||||
[
|
||||
isCollapsedWorkflowContextMenuOpen,
|
||||
closeCollapsedWorkflowContextMenu,
|
||||
captureCollapsedWorkflowSelection,
|
||||
handleCollapsedWorkflowContextMenuBase,
|
||||
workflowsHover,
|
||||
]
|
||||
)
|
||||
|
||||
const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({
|
||||
@@ -653,6 +750,10 @@ export const Sidebar = memo(function Sidebar() {
|
||||
const { selectedTasks, handleTaskClick } = useTaskSelection({ taskIds })
|
||||
|
||||
const isMultiTaskContextMenu = contextMenuSelectionRef.current.taskIds.length > 1
|
||||
const activeTaskContextMenuItem =
|
||||
!isMultiTaskContextMenu && contextMenuSelectionRef.current.taskIds.length === 1
|
||||
? tasks.find((task) => task.id === contextMenuSelectionRef.current.taskIds[0])
|
||||
: null
|
||||
|
||||
const [isTaskDeleteModalOpen, setIsTaskDeleteModalOpen] = useState(false)
|
||||
|
||||
@@ -699,19 +800,31 @@ export const Sidebar = memo(function Sidebar() {
|
||||
}, [pathname, workspaceId, deleteTaskMutation, deleteTasksMutation, navigateToPage])
|
||||
|
||||
const [visibleTaskCount, setVisibleTaskCount] = useState(5)
|
||||
const [renamingTaskId, setRenamingTaskId] = useState<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const tasksHover = useHoverMenu()
|
||||
const workflowsHover = useHoverMenu()
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
const renameCanceledRef = useRef(false)
|
||||
const taskFlyoutRename = useFlyoutInlineRename({
|
||||
itemType: 'task',
|
||||
onSave: async (taskId, name) => {
|
||||
await renameTaskMutation.mutateAsync({ chatId: taskId, title: name })
|
||||
},
|
||||
})
|
||||
|
||||
const workflowFlyoutRename = useFlyoutInlineRename({
|
||||
itemType: 'workflow',
|
||||
onSave: async (workflowIdToRename, name) => {
|
||||
await updateWorkflow(workflowIdToRename, { name })
|
||||
collapsedWorkflowContextMenuRef.current = {
|
||||
workflowId: workflowIdToRename,
|
||||
workflowName: name,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (renamingTaskId && renameInputRef.current) {
|
||||
renameInputRef.current.focus()
|
||||
renameInputRef.current.select()
|
||||
}
|
||||
}, [renamingTaskId])
|
||||
tasksHover.setLocked(isTaskContextMenuOpen || !!taskFlyoutRename.editingId)
|
||||
}, [isTaskContextMenuOpen, taskFlyoutRename.editingId, tasksHover.setLocked])
|
||||
|
||||
useEffect(() => {
|
||||
workflowsHover.setLocked(isCollapsedWorkflowContextMenuOpen || !!workflowFlyoutRename.editingId)
|
||||
}, [isCollapsedWorkflowContextMenuOpen, workflowFlyoutRename.editingId, workflowsHover.setLocked])
|
||||
|
||||
const handleTaskOpenInNewTab = useCallback(() => {
|
||||
const { taskIds: ids } = contextMenuSelectionRef.current
|
||||
@@ -719,51 +832,44 @@ export const Sidebar = memo(function Sidebar() {
|
||||
window.open(`/workspace/${workspaceId}/task/${ids[0]}`, '_blank', 'noopener,noreferrer')
|
||||
}, [workspaceId])
|
||||
|
||||
const handleMarkTaskAsRead = useCallback(() => {
|
||||
const { taskIds: ids } = contextMenuSelectionRef.current
|
||||
if (ids.length !== 1) return
|
||||
markTaskReadMutation.mutate(ids[0])
|
||||
}, [markTaskReadMutation])
|
||||
|
||||
const handleMarkTaskAsUnread = useCallback(() => {
|
||||
const { taskIds: ids } = contextMenuSelectionRef.current
|
||||
if (ids.length !== 1) return
|
||||
markTaskUnreadMutation.mutate(ids[0])
|
||||
}, [markTaskUnreadMutation])
|
||||
|
||||
const handleStartTaskRename = useCallback(() => {
|
||||
const { taskIds: ids } = contextMenuSelectionRef.current
|
||||
if (ids.length !== 1) return
|
||||
const taskId = ids[0]
|
||||
const task = tasks.find((t) => t.id === taskId)
|
||||
if (!task) return
|
||||
renameCanceledRef.current = false
|
||||
setRenamingTaskId(taskId)
|
||||
setRenameValue(task.name)
|
||||
}, [tasks])
|
||||
tasksHover.setLocked(true)
|
||||
taskFlyoutRename.startRename({ id: taskId, name: task.name })
|
||||
}, [taskFlyoutRename, tasks, tasksHover])
|
||||
|
||||
const handleSaveTaskRename = useCallback(() => {
|
||||
if (renameCanceledRef.current) {
|
||||
renameCanceledRef.current = false
|
||||
return
|
||||
}
|
||||
const trimmed = renameValue.trim()
|
||||
if (!renamingTaskId || !trimmed) {
|
||||
setRenamingTaskId(null)
|
||||
return
|
||||
}
|
||||
const task = tasks.find((t) => t.id === renamingTaskId)
|
||||
if (task && trimmed !== task.name) {
|
||||
renameTaskMutation.mutate({ chatId: renamingTaskId, title: trimmed })
|
||||
}
|
||||
setRenamingTaskId(null)
|
||||
}, [renamingTaskId, renameValue, tasks, renameTaskMutation])
|
||||
const handleCollapsedWorkflowOpenInNewTab = useCallback(() => {
|
||||
const workflow = collapsedWorkflowContextMenuRef.current
|
||||
if (!workflow) return
|
||||
window.open(
|
||||
`/workspace/${workspaceId}/w/${workflow.workflowId}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}, [workspaceId])
|
||||
|
||||
const handleCancelTaskRename = useCallback(() => {
|
||||
renameCanceledRef.current = true
|
||||
setRenamingTaskId(null)
|
||||
}, [])
|
||||
|
||||
const handleRenameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSaveTaskRename()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
handleCancelTaskRename()
|
||||
}
|
||||
},
|
||||
[handleSaveTaskRename, handleCancelTaskRename]
|
||||
)
|
||||
const handleStartCollapsedWorkflowRename = useCallback(() => {
|
||||
const workflow = collapsedWorkflowContextMenuRef.current
|
||||
if (!workflow) return
|
||||
workflowsHover.setLocked(true)
|
||||
workflowFlyoutRename.startRename({ id: workflow.workflowId, name: workflow.workflowName })
|
||||
}, [workflowFlyoutRename, workflowsHover])
|
||||
|
||||
const [hasOverflowTop, setHasOverflowTop] = useState(false)
|
||||
const [hasOverflowBottom, setHasOverflowBottom] = useState(false)
|
||||
@@ -998,14 +1104,34 @@ export const Sidebar = memo(function Sidebar() {
|
||||
<div className='flex h-full flex-col pt-[12px]'>
|
||||
{/* Top bar: Logo + Collapse toggle */}
|
||||
<div className='flex flex-shrink-0 items-center pr-[8px] pb-[8px] pl-[10px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
{showCollapsedContent ? (
|
||||
<div className='relative flex h-[30px] items-center'>
|
||||
<Link
|
||||
href={`/workspace/${workspaceId}/home`}
|
||||
className='sidebar-collapse-hide sidebar-collapse-remove flex h-[30px] items-center rounded-[8px] px-[6px] hover:bg-[var(--surface-active)]'
|
||||
tabIndex={isCollapsed ? -1 : 0}
|
||||
>
|
||||
{brand.logoUrl ? (
|
||||
<Image
|
||||
src={brand.logoUrl}
|
||||
alt={brand.name}
|
||||
width={16}
|
||||
height={16}
|
||||
className='h-[16px] w-[16px] object-contain'
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<Wordmark className='h-[16px] w-auto text-[var(--text-body)]' />
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
onClick={toggleCollapsed}
|
||||
className='group flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover:bg-[var(--surface-active)]'
|
||||
className='sidebar-collapse-show group absolute left-0 flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover:bg-[var(--surface-active)]'
|
||||
aria-label='Expand sidebar'
|
||||
tabIndex={isCollapsed ? 0 : -1}
|
||||
>
|
||||
{brand.logoUrl ? (
|
||||
<Image
|
||||
@@ -1021,32 +1147,14 @@ export const Sidebar = memo(function Sidebar() {
|
||||
)}
|
||||
<PanelLeft className='hidden h-[16px] w-[16px] rotate-180 text-[var(--text-icon)] group-hover:block' />
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={`/workspace/${workspaceId}/home`}
|
||||
className='flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
{brand.logoUrl ? (
|
||||
<Image
|
||||
src={brand.logoUrl}
|
||||
alt={brand.name}
|
||||
width={16}
|
||||
height={16}
|
||||
className='h-[16px] w-[16px] object-contain'
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<Sim className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
)}
|
||||
</Link>
|
||||
</Tooltip.Trigger>
|
||||
{isCollapsed && (
|
||||
<Tooltip.Content side='right'>
|
||||
<p>Expand sidebar</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Trigger>
|
||||
{showCollapsedContent && (
|
||||
<Tooltip.Content side='right'>
|
||||
<p>Expand sidebar</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
@@ -1097,7 +1205,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
{isOnSettingsPage ? (
|
||||
<SettingsSidebar
|
||||
isCollapsed={isCollapsed}
|
||||
showCollapsedContent={showCollapsedContent}
|
||||
showCollapsedTooltips={showCollapsedTooltips}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -1108,7 +1216,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
key={`${item.id}-${isCollapsed}`}
|
||||
item={item}
|
||||
active={item.href ? !!pathname?.startsWith(item.href) : false}
|
||||
showCollapsedContent={showCollapsedContent}
|
||||
showCollapsedTooltips={showCollapsedTooltips}
|
||||
onContextMenu={item.href ? handleNavItemContextMenu : undefined}
|
||||
/>
|
||||
))}
|
||||
@@ -1125,7 +1233,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
key={`${item.id}-${isCollapsed}`}
|
||||
item={item}
|
||||
active={item.href ? !!pathname?.startsWith(item.href) : false}
|
||||
showCollapsedContent={showCollapsedContent}
|
||||
showCollapsedTooltips={showCollapsedTooltips}
|
||||
onContextMenu={handleNavItemContextMenu}
|
||||
/>
|
||||
))}
|
||||
@@ -1169,9 +1277,12 @@ export const Sidebar = memo(function Sidebar() {
|
||||
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
}
|
||||
hover={tasksHover}
|
||||
onClick={() => navigateToPage(`/workspace/${workspaceId}/home`)}
|
||||
ariaLabel='Tasks'
|
||||
className='mt-[6px]'
|
||||
primaryAction={{
|
||||
label: 'New task',
|
||||
onSelect: () => navigateToPage(`/workspace/${workspaceId}/home`),
|
||||
}}
|
||||
>
|
||||
{tasksLoading ? (
|
||||
<DropdownMenuItem disabled>
|
||||
@@ -1180,15 +1291,21 @@ export const Sidebar = memo(function Sidebar() {
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<DropdownMenuItem key={task.id} asChild>
|
||||
<Link href={task.href}>
|
||||
<ConversationListItem
|
||||
title={task.name}
|
||||
isActive={task.isActive}
|
||||
isUnread={task.isUnread}
|
||||
/>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<CollapsedTaskFlyoutItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isCurrentRoute={task.id !== 'new' && pathname === task.href}
|
||||
isEditing={task.id === taskFlyoutRename.editingId}
|
||||
editValue={taskFlyoutRename.value}
|
||||
inputRef={taskFlyoutRename.inputRef}
|
||||
isRenaming={taskFlyoutRename.isSaving}
|
||||
onEditValueChange={taskFlyoutRename.setValue}
|
||||
onEditKeyDown={taskFlyoutRename.handleKeyDown}
|
||||
onEditBlur={() => void taskFlyoutRename.saveRename()}
|
||||
onContextMenu={handleTaskContextMenu}
|
||||
onMorePointerDown={handleTaskMorePointerDown}
|
||||
onMoreClick={handleTaskMoreClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CollapsedSidebarMenu>
|
||||
@@ -1200,7 +1317,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
<>
|
||||
{tasks.slice(0, visibleTaskCount).map((task) => {
|
||||
const isCurrentRoute = task.id !== 'new' && pathname === task.href
|
||||
const isRenaming = renamingTaskId === task.id
|
||||
const isRenaming = taskFlyoutRename.editingId === task.id
|
||||
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
|
||||
|
||||
if (isRenaming) {
|
||||
@@ -1211,11 +1328,11 @@ export const Sidebar = memo(function Sidebar() {
|
||||
>
|
||||
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={handleRenameKeyDown}
|
||||
onBlur={handleSaveTaskRename}
|
||||
ref={taskFlyoutRename.inputRef}
|
||||
value={taskFlyoutRename.value}
|
||||
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
|
||||
onKeyDown={taskFlyoutRename.handleKeyDown}
|
||||
onBlur={() => void taskFlyoutRename.saveRename()}
|
||||
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
|
||||
/>
|
||||
</div>
|
||||
@@ -1230,7 +1347,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
isSelected={isSelected}
|
||||
isActive={!!task.isActive}
|
||||
isUnread={!!task.isUnread}
|
||||
showCollapsedContent={showCollapsedContent}
|
||||
showCollapsedTooltips={showCollapsedTooltips}
|
||||
onMultiSelectClick={handleTaskClick}
|
||||
onContextMenu={handleTaskContextMenu}
|
||||
onMorePointerDown={handleTaskMorePointerDown}
|
||||
@@ -1336,9 +1453,12 @@ export const Sidebar = memo(function Sidebar() {
|
||||
/>
|
||||
}
|
||||
hover={workflowsHover}
|
||||
onClick={handleCreateWorkflow}
|
||||
ariaLabel='Workflows'
|
||||
className='mt-[6px]'
|
||||
primaryAction={{
|
||||
label: 'New workflow',
|
||||
onSelect: handleCreateWorkflow,
|
||||
}}
|
||||
>
|
||||
{workflowsLoading && regularWorkflows.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
@@ -1353,21 +1473,35 @@ export const Sidebar = memo(function Sidebar() {
|
||||
nodes={folderTree}
|
||||
workflowsByFolder={workflowsByFolder}
|
||||
workspaceId={workspaceId}
|
||||
currentWorkflowId={workflowId}
|
||||
editingWorkflowId={workflowFlyoutRename.editingId}
|
||||
editingValue={workflowFlyoutRename.value}
|
||||
editInputRef={workflowFlyoutRename.inputRef}
|
||||
isRenamingWorkflow={workflowFlyoutRename.isSaving}
|
||||
onEditValueChange={workflowFlyoutRename.setValue}
|
||||
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
|
||||
onEditBlur={() => void workflowFlyoutRename.saveRename()}
|
||||
onWorkflowContextMenu={handleCollapsedWorkflowContextMenu}
|
||||
onWorkflowMorePointerDown={handleCollapsedWorkflowMorePointerDown}
|
||||
onWorkflowMoreClick={handleCollapsedWorkflowMoreClick}
|
||||
/>
|
||||
{(workflowsByFolder.root || []).map((workflow) => (
|
||||
<DropdownMenuItem key={workflow.id} asChild>
|
||||
<Link href={`/workspace/${workspaceId}/w/${workflow.id}`}>
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: workflow.color,
|
||||
borderColor: `${workflow.color}60`,
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
<span className='truncate'>{workflow.name}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<CollapsedWorkflowFlyoutItem
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
href={`/workspace/${workspaceId}/w/${workflow.id}`}
|
||||
isCurrentRoute={workflow.id === workflowId}
|
||||
isEditing={workflow.id === workflowFlyoutRename.editingId}
|
||||
editValue={workflowFlyoutRename.value}
|
||||
inputRef={workflowFlyoutRename.inputRef}
|
||||
isRenaming={workflowFlyoutRename.isSaving}
|
||||
onEditValueChange={workflowFlyoutRename.setValue}
|
||||
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
|
||||
onEditBlur={() => void workflowFlyoutRename.saveRename()}
|
||||
onContextMenu={handleCollapsedWorkflowContextMenu}
|
||||
onMorePointerDown={handleCollapsedWorkflowMorePointerDown}
|
||||
onMoreClick={handleCollapsedWorkflowMoreClick}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
@@ -1417,7 +1551,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
</DropdownMenuTrigger>
|
||||
{showCollapsedContent && (
|
||||
{showCollapsedTooltips && (
|
||||
<Tooltip.Content side='right'>
|
||||
<p>Help</p>
|
||||
</Tooltip.Content>
|
||||
@@ -1448,7 +1582,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
key={`${item.id}-${isCollapsed}`}
|
||||
item={item}
|
||||
active={false}
|
||||
showCollapsedContent={showCollapsedContent}
|
||||
showCollapsedTooltips={showCollapsedTooltips}
|
||||
onContextMenu={item.href ? handleNavItemContextMenu : undefined}
|
||||
/>
|
||||
))}
|
||||
@@ -1471,9 +1605,17 @@ export const Sidebar = memo(function Sidebar() {
|
||||
menuRef={taskMenuRef}
|
||||
onClose={closeTaskContextMenu}
|
||||
onOpenInNewTab={handleTaskOpenInNewTab}
|
||||
onMarkAsRead={handleMarkTaskAsRead}
|
||||
onMarkAsUnread={handleMarkTaskAsUnread}
|
||||
onRename={handleStartTaskRename}
|
||||
onDelete={handleDeleteTask}
|
||||
showOpenInNewTab={!isMultiTaskContextMenu}
|
||||
showMarkAsRead={!isMultiTaskContextMenu && !!activeTaskContextMenuItem?.isUnread}
|
||||
showMarkAsUnread={
|
||||
!isMultiTaskContextMenu &&
|
||||
!!activeTaskContextMenuItem &&
|
||||
!activeTaskContextMenuItem.isUnread
|
||||
}
|
||||
showRename={!isMultiTaskContextMenu}
|
||||
showDuplicate={false}
|
||||
showColorChange={false}
|
||||
@@ -1481,6 +1623,22 @@ export const Sidebar = memo(function Sidebar() {
|
||||
disableDelete={!canEdit}
|
||||
/>
|
||||
|
||||
<ContextMenu
|
||||
isOpen={isCollapsedWorkflowContextMenuOpen}
|
||||
position={collapsedWorkflowContextMenuPosition}
|
||||
menuRef={collapsedWorkflowMenuRef}
|
||||
onClose={closeCollapsedWorkflowContextMenu}
|
||||
onOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
|
||||
onRename={handleStartCollapsedWorkflowRename}
|
||||
onDelete={() => {}}
|
||||
showOpenInNewTab={true}
|
||||
showRename={true}
|
||||
showDuplicate={false}
|
||||
showColorChange={false}
|
||||
showDelete={false}
|
||||
disableRename={!canEdit}
|
||||
/>
|
||||
|
||||
{/* Task Delete Confirmation Modal */}
|
||||
<DeleteModal
|
||||
isOpen={isTaskDeleteModalOpen}
|
||||
|
||||
@@ -396,6 +396,7 @@ Return ONLY the JSON object with properties - no explanations, no markdown, no e
|
||||
title: 'Properties to Return',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated list (e.g., "email,firstname,lastname")',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
@@ -415,6 +416,7 @@ Return ONLY the JSON object with properties - no explanations, no markdown, no e
|
||||
title: 'Associations',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated object types (e.g., "companies,deals")',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
@@ -437,9 +439,10 @@ Return ONLY the JSON object with properties - no explanations, no markdown, no e
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
title: 'Limit',
|
||||
title: 'Results Per Page',
|
||||
type: 'short-input',
|
||||
placeholder: 'Max results (list: 100, search: 200)',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
@@ -464,9 +467,10 @@ Return ONLY the JSON object with properties - no explanations, no markdown, no e
|
||||
},
|
||||
{
|
||||
id: 'after',
|
||||
title: 'After (Pagination)',
|
||||
title: 'Pagination Cursor',
|
||||
type: 'short-input',
|
||||
placeholder: 'Pagination cursor from previous response',
|
||||
placeholder: 'Cursor from previous response paging.next.after',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
@@ -714,6 +718,7 @@ Return ONLY the JSON array of filter groups - no explanations, no markdown, no e
|
||||
type: 'long-input',
|
||||
placeholder:
|
||||
'JSON array of sort objects (e.g., [{"propertyName":"createdate","direction":"DESCENDING"}])',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['search_contacts', 'search_companies', 'search_deals', 'search_tickets'],
|
||||
@@ -838,6 +843,7 @@ Return ONLY the JSON array of sort objects - no explanations, no markdown, no ex
|
||||
title: 'Properties to Return',
|
||||
type: 'long-input',
|
||||
placeholder: 'JSON array of properties (e.g., ["email","firstname","lastname"])',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['search_contacts', 'search_companies', 'search_deals', 'search_tickets'],
|
||||
|
||||
@@ -365,6 +365,29 @@ export function normalizeFileInput(
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Block types that are built-in to the platform (as opposed to third-party integrations).
|
||||
* Used to categorize tools in the tool selection dropdown.
|
||||
*/
|
||||
export const BUILT_IN_TOOL_TYPES = new Set([
|
||||
'api',
|
||||
'file',
|
||||
'function',
|
||||
'knowledge',
|
||||
'search',
|
||||
'thinking',
|
||||
'image_generator',
|
||||
'video_generator',
|
||||
'vision',
|
||||
'translate',
|
||||
'tts',
|
||||
'stt',
|
||||
'memory',
|
||||
'table',
|
||||
'webhook_request',
|
||||
'workflow',
|
||||
])
|
||||
|
||||
/**
|
||||
* Shared wand configuration for the Response Format code subblock.
|
||||
* Used by Agent and Mothership blocks.
|
||||
|
||||
@@ -84,6 +84,7 @@ export { Upload } from './upload'
|
||||
export { User } from './user'
|
||||
export { UserPlus } from './user-plus'
|
||||
export { Users } from './users'
|
||||
export { Wordmark } from './wordmark'
|
||||
export { WorkflowX } from './workflow-x'
|
||||
export { Wrap } from './wrap'
|
||||
export { Wrench } from './wrench'
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { SVGProps } from 'react'
|
||||
import { useId } from 'react'
|
||||
|
||||
/**
|
||||
* Sim brand wordmark — icon (green) + "Sim" text as a single SVG.
|
||||
* Use when expanded; use the plain `Sim` icon when collapsed.
|
||||
* @param props - SVG properties including className, style, etc.
|
||||
*/
|
||||
export function Wordmark(props: SVGProps<SVGSVGElement>) {
|
||||
const gradientId = useId()
|
||||
|
||||
return (
|
||||
<svg
|
||||
fill='none'
|
||||
height='22'
|
||||
viewBox='0 0 71 22'
|
||||
width='71'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<g transform='scale(.07483)'>
|
||||
<path
|
||||
clipRule='evenodd'
|
||||
d='m142.793 124.175c0 4.75-1.88 9.312-5.216 12.671l-.478.481c-3.334 3.369-7.863 5.252-12.58 5.252h-106.7127c-9.82776 0-17.8063 8.026-17.8063 17.924v115.407c0 9.898 7.97854 17.924 17.8063 17.924h114.5767c9.828 0 17.796-8.026 17.796-17.924v-108.052c0-4.405 1.735-8.632 4.83-11.749 3.086-3.108 7.283-4.856 11.657-4.856h108.5c9.828 0 17.796-8.024 17.796-17.923v-115.4069c0-9.89798-7.968-17.9231-17.796-17.9231h-114.578c-9.827 0-17.795 8.02512-17.795 17.9231zm34.771-99.6079h80.617c5.744 0 10.389 4.6874 10.389 10.463v81.1939c0 5.774-4.645 10.463-10.389 10.463h-80.617c-5.734 0-10.389-4.689-10.389-10.463v-81.1939c0-5.7756 4.655-10.463 10.389-10.463z'
|
||||
fill='#33c482'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
<path
|
||||
d='m275.293 171.578h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.75c0 10.402 8.373 18.834 18.7 18.834h85.187c10.328 0 18.701-8.432 18.701-18.834v-84.75c0-10.402-8.373-18.834-18.701-18.834z'
|
||||
fill='#33c482'
|
||||
/>
|
||||
<path
|
||||
d='m275.293 171.18h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.749c0 10.402 8.373 18.833 18.7 18.833h85.187c10.328 0 18.701-8.431 18.701-18.833v-84.749c0-10.402-8.373-18.834-18.701-18.834z'
|
||||
fill={`url(#${gradientId})`}
|
||||
fillOpacity='.2'
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='171.406'
|
||||
x2='245.831'
|
||||
y1='171.18'
|
||||
y2='245.428'
|
||||
>
|
||||
<stop offset='0' />
|
||||
<stop offset='1' stopOpacity='0' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</g>
|
||||
<g fill='currentColor'>
|
||||
<path d='m31.5718 15.845h2.5865c0 .7141.2586 1.2835.7759 1.7081.5173.4053 1.2166.608 2.0979.608.958 0 1.6956-.1834 2.2129-.5501.5173-.386.776-.8975.776-1.5344 0-.4632-.1437-.8492-.4311-1.158-.2682-.3088-.7664-.5597-1.4944-.7527l-2.4716-.579c-1.2453-.3088-2.1745-.7817-2.7876-1.4186-.594-.6369-.8909-1.4765-.8909-2.51873 0-.86852.2203-1.62124.661-2.25815.4598-.63692 1.0825-1.12908 1.868-1.47648.8047-.34741 1.7243-.52112 2.7589-.52112s1.9255.18336 2.6727.55007c.7664.3667 1.3603.87817 1.7818 1.53438.4407.65622.6706 1.43788.6898 2.345h-2.5865c-.0192-.73341-.2587-1.30278-.7185-1.70809-.4598-.4053-1.1017-.60796-1.9255-.60796-.843 0-1.4944.18336-1.9542.55006-.4599.36671-.6898.86852-.6898 1.50544 0 .94568.6898 1.59228 2.0692 1.93968l2.4716.608c1.1878.2702 2.0787.7141 2.6727 1.3317.5939.5983.8909 1.4186.8909 2.4608 0 .8878-.2395 1.6695-.7185 2.345-.479.6562-1.14 1.1677-1.983 1.5344-.8238.3474-1.8009.5211-2.9313.5211-1.6477 0-2.9601-.4053-3.9372-1.2159-.9772-.8106-1.4657-1.8915-1.4657-3.2425z' />
|
||||
<path d='m44.5096 19.956v-14.15687c1.0772.39383 1.5521.39383 2.7014 0v14.15687zm1.322-15.09268c-.479 0-.9005-.1737-1.2645-.52111-.3449-.36671-.5173-.79132-.5173-1.27383 0-.50181.1724-.92642.5173-1.27383.364-.34741.7855-.52111 1.2645-.52111.4981 0 .9196.1737 1.2645.52111s.5173.77202.5173 1.27383c0 .48251-.1724.90712-.5173 1.27383-.3449.34741-.7664.52111-1.2645.52111z' />
|
||||
<path d='m51.976 19.956h-2.7014v-14.15687h2.4141v2.38865c.2873-.79131.843-1.46223 1.6093-1.98334.7855-.54041 1.7339-.81062 2.8452-.81062 1.2453 0 2.2799.33776 3.1038 1.01328.8238.67551 1.3603 1.57298 1.6093 2.69241h-.4885c.1916-1.11943.7184-2.0169 1.5806-2.69241.8622-.67552 1.9255-1.01328 3.19-1.01328 1.6094 0 2.8739.47286 3.7935 1.41858.9197.94573 1.3795 2.23886 1.3795 3.8794v9.2642h-2.644v-8.5983c0-1.1195-.2874-1.97834-.8621-2.57665-.5557-.61761-1.3125-.92642-2.2704-.92642-.6706 0-1.2645.1544-1.7818.46321-.4982.28951-.8909.71412-1.1783 1.27383-.2874.55973-.4311 1.21593-.4311 1.96863v8.3957h-2.6727v-8.6273c0-1.1194-.2778-1.96864-.8334-2.54765-.5556-.59831-1.3124-.89747-2.2704-.89747-.6706 0-1.2645.1544-1.7818.46321-.4981.28951-.8909.71412-1.1783 1.27383-.2874.54038-.4311 1.18698-.4311 1.93968z' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -138,6 +138,28 @@ export function useWorkspaceCredential(credentialId?: string, enabled = true) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateCredentialDraft() {
|
||||
return useMutation({
|
||||
mutationFn: async (payload: {
|
||||
workspaceId: string
|
||||
providerId: string
|
||||
displayName: string
|
||||
description?: string
|
||||
credentialId?: string
|
||||
}) => {
|
||||
const response = await fetch('/api/credentials/draft', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Failed to create credential draft')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateWorkspaceCredential() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -165,7 +187,7 @@ export function useCreateWorkspaceCredential() {
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceCredentialKeys.lists(),
|
||||
})
|
||||
@@ -198,7 +220,7 @@ export function useUpdateWorkspaceCredential() {
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
|
||||
})
|
||||
@@ -223,7 +245,7 @@ export function useDeleteWorkspaceCredential() {
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, credentialId) => {
|
||||
onSettled: (_data, _error, credentialId) => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.detail(credentialId) })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: environmentKeys.all })
|
||||
@@ -269,7 +291,7 @@ export function useUpsertWorkspaceCredentialMember() {
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceCredentialKeys.members(variables.credentialId),
|
||||
})
|
||||
@@ -295,7 +317,7 @@ export function useRemoveWorkspaceCredentialMember() {
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceCredentialKeys.members(variables.credentialId),
|
||||
})
|
||||
|
||||
@@ -133,7 +133,7 @@ export async function fetchChatHistory(
|
||||
chatId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<TaskChatHistory> {
|
||||
const response = await fetch(`/api/copilot/chat?chatId=${chatId}`, { signal })
|
||||
const response = await fetch(`/api/mothership/chats/${chatId}`, { signal })
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load chat')
|
||||
@@ -164,10 +164,8 @@ export function useChatHistory(chatId: string | undefined) {
|
||||
}
|
||||
|
||||
async function deleteTask(chatId: string): Promise<void> {
|
||||
const response = await fetch('/api/copilot/chat/delete', {
|
||||
const response = await fetch(`/api/mothership/chats/${chatId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chatId }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete task')
|
||||
@@ -207,10 +205,10 @@ export function useDeleteTasks(workspaceId?: string) {
|
||||
}
|
||||
|
||||
async function renameTask({ chatId, title }: { chatId: string; title: string }): Promise<void> {
|
||||
const response = await fetch('/api/copilot/chat/rename', {
|
||||
const response = await fetch(`/api/mothership/chats/${chatId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chatId, title }),
|
||||
body: JSON.stringify({ title }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to rename task')
|
||||
@@ -382,16 +380,27 @@ export function useRemoveChatResource(chatId?: string) {
|
||||
}
|
||||
|
||||
async function markTaskRead(chatId: string): Promise<void> {
|
||||
const response = await fetch('/api/mothership/chats/read', {
|
||||
method: 'POST',
|
||||
const response = await fetch(`/api/mothership/chats/${chatId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chatId }),
|
||||
body: JSON.stringify({ isUnread: false }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to mark task as read')
|
||||
}
|
||||
}
|
||||
|
||||
async function markTaskUnread(chatId: string): Promise<void> {
|
||||
const response = await fetch(`/api/mothership/chats/${chatId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isUnread: true }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to mark task as unread')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a task as read with optimistic update.
|
||||
*/
|
||||
@@ -420,3 +429,32 @@ export function useMarkTaskRead(workspaceId?: string) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a task as unread with optimistic update.
|
||||
*/
|
||||
export function useMarkTaskUnread(workspaceId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: markTaskUnread,
|
||||
onMutate: async (chatId) => {
|
||||
await queryClient.cancelQueries({ queryKey: taskKeys.list(workspaceId) })
|
||||
|
||||
const previousTasks = queryClient.getQueryData<TaskMetadata[]>(taskKeys.list(workspaceId))
|
||||
|
||||
queryClient.setQueryData<TaskMetadata[]>(taskKeys.list(workspaceId), (old) =>
|
||||
old?.map((task) => (task.id === chatId ? { ...task, isUnread: true } : task))
|
||||
)
|
||||
|
||||
return { previousTasks }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousTasks) {
|
||||
queryClient.setQueryData(taskKeys.list(workspaceId), context.previousTasks)
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<svg fill="none" height="22" viewBox="0 0 71 22" width="71" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="scale(.07483)">
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="m142.793 124.175c0 4.75-1.88 9.312-5.216 12.671l-.478.481c-3.334 3.369-7.863 5.252-12.58 5.252h-106.7127c-9.82776 0-17.8063 8.026-17.8063 17.924v115.407c0 9.898 7.97854 17.924 17.8063 17.924h114.5767c9.828 0 17.796-8.026 17.796-17.924v-108.052c0-4.405 1.735-8.632 4.83-11.749 3.086-3.108 7.283-4.856 11.657-4.856h108.5c9.828 0 17.796-8.024 17.796-17.923v-115.4069c0-9.89798-7.968-17.9231-17.796-17.9231h-114.578c-9.827 0-17.795 8.02512-17.795 17.9231zm34.771-99.6079h80.617c5.744 0 10.389 4.6874 10.389 10.463v81.1939c0 5.774-4.645 10.463-10.389 10.463h-80.617c-5.734 0-10.389-4.689-10.389-10.463v-81.1939c0-5.7756 4.655-10.463 10.389-10.463z"
|
||||
fill="#33c482"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="m275.293 171.578h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.75c0 10.402 8.373 18.834 18.7 18.834h85.187c10.328 0 18.701-8.432 18.701-18.834v-84.75c0-10.402-8.373-18.834-18.701-18.834z"
|
||||
fill="#33c482"
|
||||
/>
|
||||
<path
|
||||
d="m275.293 171.18h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.749c0 10.402 8.373 18.833 18.7 18.833h85.187c10.328 0 18.701-8.431 18.701-18.833v-84.749c0-10.402-8.373-18.834-18.701-18.834z"
|
||||
fill="url(#sim-wordmark-gradient)"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sim-wordmark-gradient"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="171.406"
|
||||
x2="245.831"
|
||||
y1="171.18"
|
||||
y2="245.428"
|
||||
>
|
||||
<stop offset="0" />
|
||||
<stop offset="1" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</g>
|
||||
<g fill="#111827">
|
||||
<path d="m31.5718 15.845h2.5865c0 .7141.2586 1.2835.7759 1.7081.5173.4053 1.2166.608 2.0979.608.958 0 1.6956-.1834 2.2129-.5501.5173-.386.776-.8975.776-1.5344 0-.4632-.1437-.8492-.4311-1.158-.2682-.3088-.7664-.5597-1.4944-.7527l-2.4716-.579c-1.2453-.3088-2.1745-.7817-2.7876-1.4186-.594-.6369-.8909-1.4765-.8909-2.51873 0-.86852.2203-1.62124.661-2.25815.4598-.63692 1.0825-1.12908 1.868-1.47648.8047-.34741 1.7243-.52112 2.7589-.52112s1.9255.18336 2.6727.55007c.7664.3667 1.3603.87817 1.7818 1.53438.4407.65622.6706 1.43788.6898 2.345h-2.5865c-.0192-.73341-.2587-1.30278-.7185-1.70809-.4598-.4053-1.1017-.60796-1.9255-.60796-.843 0-1.4944.18336-1.9542.55006-.4599.36671-.6898.86852-.6898 1.50544 0 .94568.6898 1.59228 2.0692 1.93968l2.4716.608c1.1878.2702 2.0787.7141 2.6727 1.3317.5939.5983.8909 1.4186.8909 2.4608 0 .8878-.2395 1.6695-.7185 2.345-.479.6562-1.14 1.1677-1.983 1.5344-.8238.3474-1.8009.5211-2.9313.5211-1.6477 0-2.9601-.4053-3.9372-1.2159-.9772-.8106-1.4657-1.8915-1.4657-3.2425z" />
|
||||
<path d="m44.5096 19.956v-14.15687c1.0772.39383 1.5521.39383 2.7014 0v14.15687zm1.322-15.09268c-.479 0-.9005-.1737-1.2645-.52111-.3449-.36671-.5173-.79132-.5173-1.27383 0-.50181.1724-.92642.5173-1.27383.364-.34741.7855-.52111 1.2645-.52111.4981 0 .9196.1737 1.2645.52111s.5173.77202.5173 1.27383c0 .48251-.1724.90712-.5173 1.27383-.3449.34741-.7664.52111-1.2645.52111z" />
|
||||
<path d="m51.976 19.956h-2.7014v-14.15687h2.4141v2.38865c.2873-.79131.843-1.46223 1.6093-1.98334.7855-.54041 1.7339-.81062 2.8452-.81062 1.2453 0 2.2799.33776 3.1038 1.01328.8238.67551 1.3603 1.57298 1.6093 2.69241h-.4885c.1916-1.11943.7184-2.0169 1.5806-2.69241.8622-.67552 1.9255-1.01328 3.19-1.01328 1.6094 0 2.8739.47286 3.7935 1.41858.9197.94573 1.3795 2.23886 1.3795 3.8794v9.2642h-2.644v-8.5983c0-1.1195-.2874-1.97834-.8621-2.57665-.5557-.61761-1.3125-.92642-2.2704-.92642-.6706 0-1.2645.1544-1.7818.46321-.4982.28951-.8909.71412-1.1783 1.27383-.2874.55973-.4311 1.21593-.4311 1.96863v8.3957h-2.6727v-8.6273c0-1.1194-.2778-1.96864-.8334-2.54765-.5556-.59831-1.3124-.89747-2.2704-.89747-.6706 0-1.2645.1544-1.7818.46321-.4981.28951-.8909.71412-1.1783 1.27383-.2874.54038-.4311 1.18698-.4311 1.93968z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,37 @@
|
||||
<svg fill="none" height="22" viewBox="0 0 71 22" width="71" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="scale(.07483)">
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="m142.793 124.175c0 4.75-1.88 9.312-5.216 12.671l-.478.481c-3.334 3.369-7.863 5.252-12.58 5.252h-106.7127c-9.82776 0-17.8063 8.026-17.8063 17.924v115.407c0 9.898 7.97854 17.924 17.8063 17.924h114.5767c9.828 0 17.796-8.026 17.796-17.924v-108.052c0-4.405 1.735-8.632 4.83-11.749 3.086-3.108 7.283-4.856 11.657-4.856h108.5c9.828 0 17.796-8.024 17.796-17.923v-115.4069c0-9.89798-7.968-17.9231-17.796-17.9231h-114.578c-9.827 0-17.795 8.02512-17.795 17.9231zm34.771-99.6079h80.617c5.744 0 10.389 4.6874 10.389 10.463v81.1939c0 5.774-4.645 10.463-10.389 10.463h-80.617c-5.734 0-10.389-4.689-10.389-10.463v-81.1939c0-5.7756 4.655-10.463 10.389-10.463z"
|
||||
fill="#33c482"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="m275.293 171.578h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.75c0 10.402 8.373 18.834 18.7 18.834h85.187c10.328 0 18.701-8.432 18.701-18.834v-84.75c0-10.402-8.373-18.834-18.701-18.834z"
|
||||
fill="#33c482"
|
||||
/>
|
||||
<path
|
||||
d="m275.293 171.18h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.749c0 10.402 8.373 18.833 18.7 18.833h85.187c10.328 0 18.701-8.431 18.701-18.833v-84.749c0-10.402-8.373-18.834-18.701-18.834z"
|
||||
fill="url(#sim-wordmark-gradient)"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sim-wordmark-gradient"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="171.406"
|
||||
x2="245.831"
|
||||
y1="171.18"
|
||||
y2="245.428"
|
||||
>
|
||||
<stop offset="0" />
|
||||
<stop offset="1" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</g>
|
||||
<g fill="#ffffff">
|
||||
<path d="m31.5718 15.845h2.5865c0 .7141.2586 1.2835.7759 1.7081.5173.4053 1.2166.608 2.0979.608.958 0 1.6956-.1834 2.2129-.5501.5173-.386.776-.8975.776-1.5344 0-.4632-.1437-.8492-.4311-1.158-.2682-.3088-.7664-.5597-1.4944-.7527l-2.4716-.579c-1.2453-.3088-2.1745-.7817-2.7876-1.4186-.594-.6369-.8909-1.4765-.8909-2.51873 0-.86852.2203-1.62124.661-2.25815.4598-.63692 1.0825-1.12908 1.868-1.47648.8047-.34741 1.7243-.52112 2.7589-.52112s1.9255.18336 2.6727.55007c.7664.3667 1.3603.87817 1.7818 1.53438.4407.65622.6706 1.43788.6898 2.345h-2.5865c-.0192-.73341-.2587-1.30278-.7185-1.70809-.4598-.4053-1.1017-.60796-1.9255-.60796-.843 0-1.4944.18336-1.9542.55006-.4599.36671-.6898.86852-.6898 1.50544 0 .94568.6898 1.59228 2.0692 1.93968l2.4716.608c1.1878.2702 2.0787.7141 2.6727 1.3317.5939.5983.8909 1.4186.8909 2.4608 0 .8878-.2395 1.6695-.7185 2.345-.479.6562-1.14 1.1677-1.983 1.5344-.8238.3474-1.8009.5211-2.9313.5211-1.6477 0-2.9601-.4053-3.9372-1.2159-.9772-.8106-1.4657-1.8915-1.4657-3.2425z" />
|
||||
<path d="m44.5096 19.956v-14.15687c1.0772.39383 1.5521.39383 2.7014 0v14.15687zm1.322-15.09268c-.479 0-.9005-.1737-1.2645-.52111-.3449-.36671-.5173-.79132-.5173-1.27383 0-.50181.1724-.92642.5173-1.27383.364-.34741.7855-.52111 1.2645-.52111.4981 0 .9196.1737 1.2645.52111s.5173.77202.5173 1.27383c0 .48251-.1724.90712-.5173 1.27383-.3449.34741-.7664.52111-1.2645.52111z" />
|
||||
<path d="m51.976 19.956h-2.7014v-14.15687h2.4141v2.38865c.2873-.79131.843-1.46223 1.6093-1.98334.7855-.54041 1.7339-.81062 2.8452-.81062 1.2453 0 2.2799.33776 3.1038 1.01328.8238.67551 1.3603 1.57298 1.6093 2.69241h-.4885c.1916-1.11943.7184-2.0169 1.5806-2.69241.8622-.67552 1.9255-1.01328 3.19-1.01328 1.6094 0 2.8739.47286 3.7935 1.41858.9197.94573 1.3795 2.23886 1.3795 3.8794v9.2642h-2.644v-8.5983c0-1.1195-.2874-1.97834-.8621-2.57665-.5557-.61761-1.3125-.92642-2.2704-.92642-.6706 0-1.2645.1544-1.7818.46321-.4982.28951-.8909.71412-1.1783 1.27383-.2874.55973-.4311 1.21593-.4311 1.96863v8.3957h-2.6727v-8.6273c0-1.1194-.2778-1.96864-.8334-2.54765-.5556-.59831-1.3124-.89747-2.2704-.89747-.6706 0-1.2645.1544-1.7818.46321-.4981.28951-.8909.71412-1.1783 1.27383-.2874.54038-.4311 1.18698-.4311 1.93968z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
Reference in New Issue
Block a user