From e7635db73abff3ae9091d7b450d70a274c4ef825 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 30 Jun 2026 09:36:58 -0700 Subject: [PATCH 01/27] chore(logging): remove redis-progress-markers feature flag (#5287) * chore(logging): remove redis-progress-markers feature flag Promote the Redis progress-marker write path to permanent behavior now that the flag is fully rolled out. Block markers always write to Redis (primary), keeping the durable jsonb_set UPDATE fallback when Redis is unavailable. Removes the flag registry entry, its REDIS_PROGRESS_MARKERS env fallback, and the per-session flag resolution in the logging session. * chore(logging): drop vestigial readProgressMarkers plumbing Follow-up to the redis-progress-markers flag removal. With the flag gone the completion fold always reads Redis markers, so the readProgressMarkers param (and its dead false branch) is removed end-to-end: - drop the param from completeWorkflowExecution and CompleteWorkflowExecutionParams - read progress markers unconditionally in the completion fold - delete the orphaned isFeatureEnabled mock and the flag-off / flag-throws marker tests (states the code can no longer produce) --- apps/sim/lib/core/config/env.ts | 1 - apps/sim/lib/core/config/feature-flags.ts | 8 --- apps/sim/lib/logs/execution/logger.ts | 4 +- .../logs/execution/logging-session.test.ts | 54 +------------------ .../sim/lib/logs/execution/logging-session.ts | 39 +++----------- apps/sim/lib/logs/types.ts | 7 --- 6 files changed, 11 insertions(+), 102 deletions(-) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 5f313138a3f..dd88a3ec5bb 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -77,7 +77,6 @@ export const env = createEnv({ TABLE_SNAPSHOT_CACHE: z.boolean().optional(), // Mount tables into sandboxes by reference via a version-keyed CSV snapshot in object storage instead of draining the whole table into web-process heap PII_REDACTION: z.boolean().optional(), // Redact PII from workflow logs via configurable Data Retention rules (Presidio at the logger persist choke point) and expose the Data Retention config UI TRIGGER_EU_REGION: z.boolean().optional(), // Route Trigger.dev runs to eu-central-1 instead of the default us-east-1 (fallback for the trigger-eu-region flag when AppConfig is not the source of truth) - REDIS_PROGRESS_MARKERS: z.boolean().optional(), // Write per-block live progress markers to Redis instead of jsonb_set UPDATEs on workflow_execution_logs (fallback for the redis-progress-markers flag when AppConfig is not the source of truth) // Table feature limits (per plan). Apply when billing is disabled (free tier defaults) or for billed plans. FREE_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on free tier (default: 5) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 6f3a3b4847b..4cbeeab0ae0 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -97,14 +97,6 @@ const FEATURE_FLAGS = { 'resolveTriggerRegion, so the whole deployment switches regions together.', fallback: 'TRIGGER_EU_REGION', }, - 'redis-progress-markers': { - description: - 'Write per-block live progress markers (lastStartedBlock/lastCompletedBlock) to Redis ' + - 'instead of jsonb_set UPDATEs on workflow_execution_logs, folding them into the single ' + - 'terminal UPDATE at completion. Eliminates the heaviest write query. Resolved once per ' + - 'logging session (no user/org context) so an execution never mixes write paths.', - fallback: 'REDIS_PROGRESS_MARKERS', - }, 'workspace-forking': { description: 'Runtime rollout gate for workspace forking (fork/promote/rollback), layered on top of ' + diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index a78225d42f5..539b87174e6 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -679,7 +679,6 @@ export class ExecutionLogger implements IExecutionLoggerService { isResume?: boolean level?: 'info' | 'error' status?: 'completed' | 'failed' | 'cancelled' | 'pending' - readProgressMarkers?: boolean }): Promise { const { executionId, @@ -695,7 +694,6 @@ export class ExecutionLogger implements IExecutionLoggerService { isResume, level: levelOverride, status: statusOverride, - readProgressMarkers = true, } = params let execLog = logger.withMetadata({ executionId }) @@ -753,7 +751,7 @@ export class ExecutionLogger implements IExecutionLoggerService { models: costSummary.models, } - const progressMarkers = readProgressMarkers ? await getProgressMarkers(executionId) : null + const progressMarkers = await getProgressMarkers(executionId) const builtExecutionData = this.buildCompletedExecutionData({ existingExecutionData, diff --git a/apps/sim/lib/logs/execution/logging-session.test.ts b/apps/sim/lib/logs/execution/logging-session.test.ts index cf978fccd5f..57eebb8e260 100644 --- a/apps/sim/lib/logs/execution/logging-session.test.ts +++ b/apps/sim/lib/logs/execution/logging-session.test.ts @@ -67,23 +67,17 @@ vi.mock('@/lib/logs/execution/logger', () => ({ })) const { - isFeatureEnabledMock, setLastStartedBlockMock, setLastCompletedBlockMock, getProgressMarkersMock, clearProgressMarkersMock, } = vi.hoisted(() => ({ - isFeatureEnabledMock: vi.fn().mockResolvedValue(false), setLastStartedBlockMock: vi.fn().mockResolvedValue(false), setLastCompletedBlockMock: vi.fn().mockResolvedValue(false), getProgressMarkersMock: vi.fn().mockResolvedValue({}), clearProgressMarkersMock: vi.fn().mockResolvedValue(undefined), })) -vi.mock('@/lib/core/config/feature-flags', () => ({ - isFeatureEnabled: isFeatureEnabledMock, -})) - vi.mock('@/lib/logs/execution/progress-markers', () => ({ setLastStartedBlock: setLastStartedBlockMock, setLastCompletedBlock: setLastCompletedBlockMock, @@ -720,8 +714,7 @@ describe('LoggingSession progress-marker write path', () => { dbMocks.execute.mockResolvedValue(undefined) }) - it('writes markers to Redis (not the row) when the flag is on and Redis accepts the write', async () => { - isFeatureEnabledMock.mockResolvedValue(true) + it('writes markers to Redis (not the row) when Redis accepts the write', async () => { setLastStartedBlockMock.mockResolvedValue(true) setLastCompletedBlockMock.mockResolvedValue(true) const session = new LoggingSession('wf-1', 'exec-redis', 'manual', 'req-1') @@ -741,8 +734,7 @@ describe('LoggingSession progress-marker write path', () => { expect(dbMocks.execute).not.toHaveBeenCalled() }) - it('falls back to the SQL UPDATE when the flag is on but the Redis write fails', async () => { - isFeatureEnabledMock.mockResolvedValue(true) + it('falls back to the SQL UPDATE when the Redis write fails', async () => { setLastStartedBlockMock.mockResolvedValue(false) const session = new LoggingSession('wf-1', 'exec-redis-down', 'manual', 'req-1') await session.start({ workspaceId: 'ws-1' }) @@ -752,46 +744,4 @@ describe('LoggingSession progress-marker write path', () => { expect(setLastStartedBlockMock).toHaveBeenCalled() expect(dbMocks.execute).toHaveBeenCalledTimes(1) }) - - it('writes markers via jsonb_set UPDATE when the flag is off', async () => { - isFeatureEnabledMock.mockResolvedValue(false) - const session = new LoggingSession('wf-1', 'exec-sql', 'manual', 'req-1') - await session.start({ workspaceId: 'ws-1' }) - - await session.onBlockStart('b1', 'Fetch', 'api', '2026-06-27T10:00:00.000Z') - - expect(dbMocks.execute).toHaveBeenCalledTimes(1) - expect(setLastStartedBlockMock).not.toHaveBeenCalled() - }) - - it('falls back to the SQL path when flag resolution throws', async () => { - isFeatureEnabledMock.mockRejectedValue(new Error('appconfig unavailable')) - const session = new LoggingSession('wf-1', 'exec-fallback', 'manual', 'req-1') - await session.start({ workspaceId: 'ws-1' }) - - await session.onBlockStart('b1', 'Fetch', 'api', '2026-06-27T10:00:00.000Z') - - expect(dbMocks.execute).toHaveBeenCalledTimes(1) - expect(setLastStartedBlockMock).not.toHaveBeenCalled() - }) - - it('tells completion to read Redis markers only when the flag is on (no wasted ops when off)', async () => { - completeWorkflowExecutionMock.mockResolvedValue({}) - - isFeatureEnabledMock.mockResolvedValue(true) - const onSession = new LoggingSession('wf-1', 'exec-on', 'manual', 'req-1') - await onSession.start({ workspaceId: 'ws-1' }) - await onSession.safeComplete({ finalOutput: { ok: true } }) - expect(completeWorkflowExecutionMock).toHaveBeenLastCalledWith( - expect.objectContaining({ executionId: 'exec-on', readProgressMarkers: true }) - ) - - isFeatureEnabledMock.mockResolvedValue(false) - const offSession = new LoggingSession('wf-1', 'exec-off', 'manual', 'req-1') - await offSession.start({ workspaceId: 'ws-1' }) - await offSession.safeComplete({ finalOutput: { ok: true } }) - expect(completeWorkflowExecutionMock).toHaveBeenLastCalledWith( - expect.objectContaining({ executionId: 'exec-off', readProgressMarkers: false }) - ) - }) }) diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index d6d7c271fff..1e68ba0f060 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger' import { describeError, toError } from '@sim/utils/errors' import { and, eq, sql } from 'drizzle-orm' import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure' import { executionLogger } from '@/lib/logs/execution/logger' import { @@ -136,13 +135,6 @@ export class LoggingSession { private workflowState?: WorkflowState private correlation?: NonNullable['correlation'] private isResume = false - /** - * Whether per-block progress markers go to Redis (vs jsonb_set UPDATEs on the - * log row). Resolved once in {@link start} and cached so an execution never - * mixes write paths across its block callbacks. Defaults to the legacy SQL - * path until resolved. - */ - private useRedisMarkers = false private completed = false /** Synchronous flag to prevent concurrent completion attempts (race condition guard) */ private completing = false @@ -182,24 +174,12 @@ export class LoggingSession { } /** - * Resolve the per-block marker write path (Redis vs jsonb_set UPDATE) for this - * session. Defaults to the legacy SQL path if flag resolution fails. - */ - private async resolveRedisMarkerMode(): Promise { - try { - return await isFeatureEnabled('redis-progress-markers') - } catch { - return false - } - } - - /** - * Persist the last-started-block marker. Redis is the primary path when the - * flag is on; falls back to the durable jsonb_set UPDATE when Redis is - * unavailable or the write fails, so a marker is never dropped. + * Persist the last-started-block marker. Redis is the primary path; falls back + * to the durable jsonb_set UPDATE when Redis is unavailable or the write fails, + * so a marker is never dropped. */ private async persistLastStartedBlock(marker: ExecutionLastStartedBlock): Promise { - if (this.useRedisMarkers && (await setLastStartedBlock(this.executionId, marker))) { + if (await setLastStartedBlock(this.executionId, marker)) { return } try { @@ -220,12 +200,12 @@ export class LoggingSession { } /** - * Persist the last-completed-block marker. Redis is the primary path when the - * flag is on; falls back to the durable jsonb_set UPDATE when Redis is - * unavailable or the write fails, so a marker is never dropped. + * Persist the last-completed-block marker. Redis is the primary path; falls + * back to the durable jsonb_set UPDATE when Redis is unavailable or the write + * fails, so a marker is never dropped. */ private async persistLastCompletedBlock(marker: ExecutionLastCompletedBlock): Promise { - if (this.useRedisMarkers && (await setLastCompletedBlock(this.executionId, marker))) { + if (await setLastCompletedBlock(this.executionId, marker)) { return } try { @@ -308,7 +288,6 @@ export class LoggingSession { isResume: this.isResume, level: params.level, status: params.status, - readProgressMarkers: this.useRedisMarkers, }) // Release the admission reservation from preprocessing. Skipped on pause: a @@ -356,8 +335,6 @@ export class LoggingSession { } = params try { - this.useRedisMarkers = await this.resolveRedisMarkerMode() - this.trigger = createTriggerObject(this.triggerType, triggerData) this.correlation = triggerData?.correlation this.environment = createEnvironmentObject( diff --git a/apps/sim/lib/logs/types.ts b/apps/sim/lib/logs/types.ts index f146c27699b..f4021439a35 100644 --- a/apps/sim/lib/logs/types.ts +++ b/apps/sim/lib/logs/types.ts @@ -463,12 +463,5 @@ export interface ExecutionLoggerService { isResume?: boolean level?: 'info' | 'error' status?: 'completed' | 'failed' | 'cancelled' | 'pending' - /** - * Whether this session wrote live progress markers to Redis. When false, the - * completion fold skips the Redis read/clear entirely (markers are already on - * the row via the SQL path). Defaults to true so non-session callers keep the - * safe read-and-fold behavior. - */ - readProgressMarkers?: boolean }): Promise } From 4298e577e593fd99a1d558a98ede39ddd6e88323 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 30 Jun 2026 10:11:24 -0700 Subject: [PATCH 02/27] fix(workflow-renderer): validate dropbox host in note embed renderer (#5288) * fix(workflow-renderer): validate dropbox host in note embed renderer Replace the bare url.includes('dropbox.com') check with a parsed-hostname match so attacker-controlled hosts (dropbox.com.evil.com, evil.com/?dropbox.com) no longer get treated as direct dropbox videos. Resolves CodeQL js/incomplete-url-substring-sanitization (#430). * fix(workflow-renderer): rewrite dropbox embed via parsed URL, tolerate scheme-less links Derive the direct video URL from the parsed URL object (rewrite hostname to dl.dropboxusercontent.com for any dropbox.com/*.dropbox.com host) instead of a www-only string replace, and accept scheme-less links. Fixes broken embeds for m.dropbox.com / bare-host links flagged in review. --- .../src/note/note-block-view.tsx | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/workflow-renderer/src/note/note-block-view.tsx b/packages/workflow-renderer/src/note/note-block-view.tsx index cf1dac38e8f..70f6be2fbbb 100644 --- a/packages/workflow-renderer/src/note/note-block-view.tsx +++ b/packages/workflow-renderer/src/note/note-block-view.tsx @@ -17,6 +17,34 @@ function getTwitchParent(): string { return typeof window !== 'undefined' ? window.location.hostname : 'localhost' } +/** Parse a URL, tolerating scheme-less inputs (https is assumed). Returns null if unparseable. */ +function parseUrl(url: string): URL | null { + for (const candidate of [url, `https://${url}`]) { + try { + return new URL(candidate) + } catch {} + } + return null +} + +/** + * Resolve a Dropbox share link to a direct, embeddable video URL. Accepts only URLs + * whose host is `dropbox.com` or a `*.dropbox.com` subdomain (so attacker-controlled + * hosts like `dropbox.com.evil.com` are rejected), then rewrites the host to + * `dl.dropboxusercontent.com` so the file streams as media. Returns null for any + * non-Dropbox host or non-video path. + */ +function getDropboxDirectVideoUrl(url: string): string | null { + const parsed = parseUrl(url) + if (!parsed) return null + const host = parsed.hostname.toLowerCase() + if (host !== 'dropbox.com' && !host.endsWith('.dropbox.com')) return null + if (!/\.(mp4|mov|webm)$/i.test(parsed.pathname)) return null + parsed.hostname = 'dl.dropboxusercontent.com' + parsed.searchParams.delete('dl') + return parsed.toString() +} + /** * Get embed info for supported media platforms */ @@ -250,11 +278,9 @@ function getEmbedInfo(url: string): EmbedInfo | null { return { url: `https://drive.google.com/file/d/${googleDriveMatch[1]}/preview`, type: 'iframe' } } - if (url.includes('dropbox.com') && /\.(mp4|mov|webm)/.test(url)) { - const directUrl = url - .replace('www.dropbox.com', 'dl.dropboxusercontent.com') - .replace('?dl=0', '') - return { url: directUrl, type: 'video' } + const dropboxDirectVideoUrl = getDropboxDirectVideoUrl(url) + if (dropboxDirectVideoUrl) { + return { url: dropboxDirectVideoUrl, type: 'video' } } const tenorMatch = url.match(/tenor\.com\/view\/[^/]+-(\d+)/) From ca0a7ff0c2abc1bb3d17a3f3717d18d979f82d50 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 30 Jun 2026 11:27:42 -0700 Subject: [PATCH 03/27] feat(rich-markdown-editor): live media embeds + shared embed detection util (#5290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(rich-markdown-editor): live media embeds + shared embed detection util - Extract getEmbedInfo/EmbedInfo into pure @sim/utils/media-embed (carries the PR #5288 dropbox host-validation hardening); repoint the note block to it - Add LinkEmbed: a ProseMirror widget-decoration plugin that renders media players (YouTube, Vimeo, Spotify, Dropbox, …) beneath standalone links in the rich markdown editor, in both editing and read-only surfaces. The document stays a plain markdown link, so markdown round-trips stay lossless - Gate embeds behind an opt-in flag (on for the file editor, off for modal fields) - Polish the knowledge chunk editor to the file editor's centered reading frame while keeping it plaintext for exact embedding fidelity * fix(media-embed): gate provider detection on parsed hostname Validate each platform against the URL's parsed host before extracting, so a look-alike host (youtube.com.evil.com) or a provider domain in the path (evil.com/youtube.com/...) can no longer render a trusted-looking embed. Dropbox is no longer a special case — all providers share the hostMatches gate. Also consolidates the five Spotify branches and orders Twitch clip before channel. * fix(rich-markdown-editor): unique widget key per duplicate embed URL Key embed widgets by source + per-source occurrence index so two standalone links to the same URL render as two distinct players instead of collapsing into one, while keeping the key stable across unrelated edits (no iframe reload). * refactor(media-embed): tighten comments and drop a redundant guard - Drop the redundant paragraph type-check in getStandaloneLinkHref (the caller already filters to paragraphs) and rename the param for clarity - Remove an inline comment and a TSDoc sentence that restated logic documented elsewhere --- .../rich-markdown-editor/editor-extensions.ts | 8 +- .../rich-markdown-editor/embed/embed-dom.ts | 62 ++++ .../embed/link-embed.test.ts | 64 ++++ .../rich-markdown-editor/embed/link-embed.ts | 88 +++++ .../rich-markdown-editor.tsx | 1 + .../components/chunk-editor/chunk-editor.tsx | 6 +- bun.lock | 2 + packages/utils/package.json | 4 + packages/utils/src/index.ts | 2 + packages/utils/src/media-embed.test.ts | 84 +++++ packages/utils/src/media-embed.ts | 339 ++++++++++++++++++ packages/workflow-renderer/package.json | 2 + .../src/note/note-block-view.tsx | 298 +-------------- 13 files changed, 659 insertions(+), 301 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/embed-dom.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.ts create mode 100644 packages/utils/src/media-embed.test.ts create mode 100644 packages/utils/src/media-embed.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts index 675a1f487cc..205da75a97d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -2,6 +2,7 @@ import type { Extensions } from '@tiptap/core' import Placeholder from '@tiptap/extension-placeholder' import { CodeBlockWithLanguage } from './code-block' import { CodeBlockHighlight } from './code-highlight' +import { LinkEmbed } from './embed/link-embed' import { createMarkdownContentExtensions } from './extensions' import { ResizableImage } from './image' import { RichMarkdownKeymap } from './keymap' @@ -12,19 +13,23 @@ import { SlashCommand } from './slash-command/slash-command' interface MarkdownEditorExtensionOptions { placeholder: string + /** Renders supported media links as live players beneath a standalone link. Off by default. */ + embeds?: boolean } /** * The full extension set for the live editor: the content extensions with their React node-view nodes * injected (code-block language picker, resizable image, mention chip) plus the UI-only extensions — * `CodeBlockHighlight` (Prism), `SlashCommand` (the `/` block menu), `Mention` (the `@` menu), - * `RichMarkdownKeymap`, `MarkdownPaste`, and `Placeholder`. + * `RichMarkdownKeymap`, `MarkdownPaste`, `Placeholder`, and — when `embeds` is set — `LinkEmbed` + * (media players for standalone links). * * Kept separate from `extensions.ts` so those node views (and the block registry the mention chip pulls * in for brand icons) stay out of the headless round-trip path, which only needs the schema. */ export function createMarkdownEditorExtensions({ placeholder, + embeds = false, }: MarkdownEditorExtensionOptions): Extensions { return [ ...createMarkdownContentExtensions({ @@ -38,5 +43,6 @@ export function createMarkdownEditorExtensions({ RichMarkdownKeymap, MarkdownPaste, Placeholder.configure({ placeholder }), + ...(embeds ? [LinkEmbed] : []), ] } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/embed-dom.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/embed-dom.ts new file mode 100644 index 00000000000..122bccf58e8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/embed-dom.ts @@ -0,0 +1,62 @@ +import type { EmbedInfo } from '@sim/utils/media-embed' + +/** + * Iframes are rendered at native size then CSS-scaled down so embedded players keep their + * intended layout inside the editor's reading column. Mirrors the note-block renderer. + */ +const EMBED_SCALE = 0.78 +const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%` + +const IFRAME_ALLOW = + 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share' + +/** + * Build the DOM player for a resolved {@link EmbedInfo}, matching the note-block renderer's + * markup. Returned as a non-editable element so it can back a ProseMirror widget decoration + * without entering the editable content. + */ +export function createEmbedDom(embedInfo: EmbedInfo): HTMLElement { + const container = document.createElement('div') + container.className = 'my-2 block w-full overflow-hidden rounded-md' + container.contentEditable = 'false' + + if (embedInfo.type === 'iframe') { + const frame = document.createElement('div') + frame.className = 'block overflow-hidden' + frame.style.width = '100%' + frame.style.aspectRatio = embedInfo.aspectRatio || '16/9' + + const iframe = document.createElement('iframe') + iframe.src = embedInfo.url + iframe.title = 'Media' + iframe.allow = IFRAME_ALLOW + iframe.allowFullscreen = true + iframe.loading = 'lazy' + iframe.className = 'origin-top-left' + iframe.style.width = EMBED_INVERSE_SCALE + iframe.style.height = EMBED_INVERSE_SCALE + iframe.style.transform = `scale(${EMBED_SCALE})` + + frame.appendChild(iframe) + container.appendChild(frame) + return container + } + + if (embedInfo.type === 'video') { + const video = document.createElement('video') + video.src = embedInfo.url + video.controls = true + video.preload = 'metadata' + video.className = 'aspect-video w-full' + container.appendChild(video) + return container + } + + const audio = document.createElement('audio') + audio.src = embedInfo.url + audio.controls = true + audio.preload = 'metadata' + audio.className = 'w-full' + container.appendChild(audio) + return container +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.test.ts new file mode 100644 index 00000000000..ad5437066ff --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.test.ts @@ -0,0 +1,64 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { createMarkdownEditorExtensions } from '../editor-extensions' + +// jsdom lacks elementFromPoint, which TipTap's Placeholder viewport tracking calls on mount. +beforeAll(() => { + document.elementFromPoint = vi.fn(() => null) +}) + +let editor: Editor | null = null + +function editorWith(content: string, embeds = true): Editor { + editor = new Editor({ + extensions: createMarkdownEditorExtensions({ placeholder: '', embeds }), + content, + }) + return editor +} + +afterEach(() => { + editor?.destroy() + editor = null +}) + +const YOUTUBE_LINK = '

watch

' + +describe('LinkEmbed', () => { + it('renders a player beneath a standalone embeddable link', () => { + const view = editorWith(YOUTUBE_LINK).view + const iframe = view.dom.querySelector('iframe') + expect(iframe?.getAttribute('src')).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ') + }) + + it('renders one player per link when the same URL appears twice', () => { + const view = editorWith(`${YOUTUBE_LINK}${YOUTUBE_LINK}`).view + expect(view.dom.querySelectorAll('iframe')).toHaveLength(2) + }) + + it('keeps the underlying document a plain markdown link (lossless round-trip)', () => { + const markdown = editorWith(YOUTUBE_LINK).getMarkdown() + expect(markdown).toContain('https://www.youtube.com/watch?v=dQw4w9WgXcQ') + expect(markdown).not.toContain(' { + const view = editorWith( + '

see here now

' + ).view + expect(view.dom.querySelector('iframe')).toBeNull() + }) + + it('does not embed a non-embeddable standalone link', () => { + const view = editorWith('

read

').view + expect(view.dom.querySelector('iframe')).toBeNull() + }) + + it('does nothing when the embeds option is disabled', () => { + const view = editorWith(YOUTUBE_LINK, false).view + expect(view.dom.querySelector('iframe')).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.ts new file mode 100644 index 00000000000..b76cf241562 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.ts @@ -0,0 +1,88 @@ +import { getEmbedInfo } from '@sim/utils/media-embed' +import { Extension } from '@tiptap/core' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { createEmbedDom } from './embed-dom' + +const LINK_EMBED_PLUGIN_KEY = new PluginKey('linkEmbed') + +/** + * The href of a paragraph that is a single, whole-text link (a "standalone link"), or null if + * the paragraph is empty, holds non-text content, or mixes a link with other text. Only + * standalone links become media embeds — a link inline within a sentence stays a plain link, + * matching how Notion and Linear auto-embed. + */ +function getStandaloneLinkHref(paragraph: ProseMirrorNode): string | null { + if (paragraph.childCount === 0) return null + let href: string | null = null + let isStandalone = true + paragraph.forEach((child) => { + if (!isStandalone) return + const linkMark = child.isText + ? child.marks.find((mark) => mark.type.name === 'link') + : undefined + if (!linkMark) { + isStandalone = false + return + } + const childHref = linkMark.attrs.href as string + if (href === null) href = childHref + else if (href !== childHref) isStandalone = false + }) + return isStandalone ? href : null +} + +function buildDecorations(doc: ProseMirrorNode): DecorationSet { + const decorations: Decoration[] = [] + /** Per-source occurrence count, so repeated embeds of the same URL get distinct, stable keys. */ + const sourceCounts = new Map() + doc.descendants((node, pos) => { + if (node.type.name !== 'paragraph') return undefined + const href = getStandaloneLinkHref(node) + if (href) { + const embedInfo = getEmbedInfo(href) + if (embedInfo) { + const source = `embed:${embedInfo.type}:${embedInfo.url}` + const index = sourceCounts.get(source) ?? 0 + sourceCounts.set(source, index + 1) + decorations.push( + Decoration.widget(pos + node.nodeSize, () => createEmbedDom(embedInfo), { + side: 1, + key: `${source}:${index}`, + }) + ) + } + } + // Paragraphs hold only inline content, so there is nothing more to descend into. + return false + }) + return DecorationSet.create(doc, decorations) +} + +/** + * Renders supported media links (YouTube, Vimeo, Spotify, Dropbox, …) as live players beneath a + * standalone link, in both the editing and read-only surfaces. Implemented as widget decorations + * so the underlying document stays a plain markdown link — embeds never enter the schema or the + * serialized markdown, keeping round-trips lossless. + */ +export const LinkEmbed = Extension.create({ + name: 'linkEmbed', + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: LINK_EMBED_PLUGIN_KEY, + state: { + init: (_, { doc }) => buildDecorations(doc), + apply: (tr, current) => (tr.docChanged ? buildDecorations(tr.doc) : current), + }, + props: { + decorations(state) { + return LINK_EMBED_PLUGIN_KEY.getState(state) + }, + }, + }), + ] + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index f8a8286d221..db394d23174 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -31,6 +31,7 @@ import './rich-markdown-editor.css' const EXTENSIONS = createMarkdownEditorExtensions({ placeholder: "Write something, or press '/' for commands…", + embeds: true, }) /** Throttle the per-frame full re-parse above this body size so a large streaming file can't saturate the main thread. */ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx index cc194295460..bdab7e2a328 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx @@ -205,7 +205,7 @@ export function ChunkEditor({
{ if (e.target === e.currentTarget) textareaRef.current?.focus() }} @@ -217,7 +217,7 @@ export function ChunkEditor({ {tokenizerOn ? (
{tokenStrings.map((token, index) => ( =0.479.0", "react": "^19", "reactflow": "^11.11.4", diff --git a/packages/utils/package.json b/packages/utils/package.json index 9de01b4a73e..6413ed1d0eb 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -26,6 +26,10 @@ "types": "./src/helpers.ts", "default": "./src/helpers.ts" }, + "./media-embed": { + "types": "./src/media-embed.ts", + "default": "./src/media-embed.ts" + }, "./formatting": { "types": "./src/formatting.ts", "default": "./src/formatting.ts" diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index eb89a3de69a..fe2ab2f3b98 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -12,6 +12,8 @@ export { } from './formatting.js' export { noop, sleep } from './helpers.js' export { generateId, generateShortId, isValidUuid } from './id.js' +export type { EmbedInfo } from './media-embed.js' +export { getEmbedInfo } from './media-embed.js' export { filterUndefined, isPlainRecord, diff --git a/packages/utils/src/media-embed.test.ts b/packages/utils/src/media-embed.test.ts new file mode 100644 index 00000000000..6422ebfa5c0 --- /dev/null +++ b/packages/utils/src/media-embed.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { getEmbedInfo } from './media-embed' + +describe('getEmbedInfo', () => { + it('maps YouTube watch/short/embed URLs to the embed iframe', () => { + const expected = { url: 'https://www.youtube.com/embed/dQw4w9WgXcQ', type: 'iframe' } + expect(getEmbedInfo('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toEqual(expected) + expect(getEmbedInfo('https://youtu.be/dQw4w9WgXcQ')).toEqual(expected) + expect(getEmbedInfo('https://www.youtube.com/embed/dQw4w9WgXcQ')).toEqual(expected) + }) + + it('maps Vimeo and Spotify URLs with their aspect ratios', () => { + expect(getEmbedInfo('https://vimeo.com/123456')).toEqual({ + url: 'https://player.vimeo.com/video/123456', + type: 'iframe', + }) + expect(getEmbedInfo('https://open.spotify.com/track/abc123')).toEqual({ + url: 'https://open.spotify.com/embed/track/abc123', + type: 'iframe', + aspectRatio: '3.7/1', + }) + }) + + it('treats bare media file extensions as native video/audio', () => { + expect(getEmbedInfo('https://cdn.example.com/clip.mp4')).toEqual({ + url: 'https://cdn.example.com/clip.mp4', + type: 'video', + }) + expect(getEmbedInfo('https://cdn.example.com/sound.mp3')).toEqual({ + url: 'https://cdn.example.com/sound.mp3', + type: 'audio', + }) + }) + + it('returns null for non-embeddable URLs', () => { + expect(getEmbedInfo('https://example.com/article')).toBeNull() + expect(getEmbedInfo('not a url')).toBeNull() + }) + + it('only embeds when the parsed host belongs to the provider', () => { + // A provider domain in the path or as a subdomain prefix of an attacker host + // must not be treated as that provider. + expect(getEmbedInfo('https://evil.com/youtube.com/watch?v=dQw4w9WgXcQ')).toBeNull() + expect(getEmbedInfo('https://youtube.com.evil.com/watch?v=dQw4w9WgXcQ')).toBeNull() + expect(getEmbedInfo('https://evil.com/open.spotify.com/track/abc123')).toBeNull() + expect(getEmbedInfo('https://vimeo.com.evil.com/123456')).toBeNull() + // Legitimate subdomains of a provider still embed. + expect(getEmbedInfo('https://m.youtube.com/watch?v=dQw4w9WgXcQ')).toEqual({ + url: 'https://www.youtube.com/embed/dQw4w9WgXcQ', + type: 'iframe', + }) + }) + + describe('Dropbox', () => { + it('rewrites a Dropbox video share link to a direct streamable URL', () => { + expect(getEmbedInfo('https://www.dropbox.com/s/abc/clip.mp4?dl=0')).toEqual({ + url: 'https://dl.dropboxusercontent.com/s/abc/clip.mp4', + type: 'video', + }) + }) + + it('handles non-www and scheme-less Dropbox hosts', () => { + expect(getEmbedInfo('https://m.dropbox.com/s/abc/clip.mov')).toEqual({ + url: 'https://dl.dropboxusercontent.com/s/abc/clip.mov', + type: 'video', + }) + expect(getEmbedInfo('dropbox.com/s/abc/clip.webm')).toEqual({ + url: 'https://dl.dropboxusercontent.com/s/abc/clip.webm', + type: 'video', + }) + }) + + it('does not apply the Dropbox direct-link rewrite to look-alike hosts', () => { + // Look-alike hosts fall through to the generic video handler with their + // original (untrusted) host intact — never rewritten as if trusted Dropbox. + expect(getEmbedInfo('https://dropbox.com.evil.com/clip.mp4')?.url).not.toContain( + 'dropboxusercontent.com' + ) + expect(getEmbedInfo('https://evil.com/?x=dropbox.com/clip.mp4')?.url).not.toContain( + 'dropboxusercontent.com' + ) + }) + }) +}) diff --git a/packages/utils/src/media-embed.ts b/packages/utils/src/media-embed.ts new file mode 100644 index 00000000000..1432e22198d --- /dev/null +++ b/packages/utils/src/media-embed.ts @@ -0,0 +1,339 @@ +/** + * Resolved embed for a media URL: the iframe/video/audio source to render plus + * an optional aspect ratio hint. Renderers own the surrounding markup; this + * module only decides whether a URL is embeddable and what source to use. + */ +export interface EmbedInfo { + url: string + type: 'iframe' | 'video' | 'audio' + aspectRatio?: string +} + +/** + * The `parent` query param required by Twitch embeds. Reads the current host in + * the browser and falls back to `localhost` during SSR. + */ +function getTwitchParent(): string { + return typeof window !== 'undefined' ? window.location.hostname : 'localhost' +} + +/** Parse a URL, tolerating scheme-less inputs (https is assumed). Returns null if unparseable. */ +function parseUrl(url: string): URL | null { + for (const candidate of [url, `https://${url}`]) { + try { + return new URL(candidate) + } catch {} + } + return null +} + +/** + * Whether `host` is one of `domains` or a subdomain of one (e.g. `m.youtube.com` + * matches `youtube.com`). A null host (unparseable URL) never matches. This is the + * security boundary for provider detection: a link is only treated as a given + * platform when its parsed host actually belongs to that platform, so look-alikes + * like `youtube.com.evil.com` or `evil.com/youtube.com/...` are rejected. + */ +function hostMatches(host: string | null, ...domains: string[]): boolean { + if (host === null) return false + return domains.some((domain) => host === domain || host.endsWith(`.${domain}`)) +} + +/** + * Rewrite a Dropbox share URL's host to `dl.dropboxusercontent.com` so the file + * streams as media, returning null for a non-video path. The caller has already + * verified the host is Dropbox. + */ +function toDropboxDirectVideoUrl(parsed: URL): string | null { + if (!/\.(mp4|mov|webm)$/i.test(parsed.pathname)) return null + parsed.hostname = 'dl.dropboxusercontent.com' + parsed.searchParams.delete('dl') + return parsed.toString() +} + +/** + * Map a URL to its embeddable form across supported media platforms (YouTube, + * Vimeo, Spotify, Apple Music, Twitch, Dropbox, Giphy, and many more), plus + * generic video/audio file extensions. Returns null when the URL is not a + * recognized embeddable source. + * + * Each platform is gated on its parsed hostname via {@link hostMatches} before its + * id-extracting regex runs. The generic file-extension fallbacks are intentionally + * host-agnostic — any direct media file URL is embeddable. + */ +export function getEmbedInfo(url: string): EmbedInfo | null { + const parsed = parseUrl(url) + const host = parsed?.hostname.toLowerCase() ?? null + if (hostMatches(host, 'youtube.com', 'youtu.be')) { + const youtubeMatch = url.match( + /(?:youtube\.com\/watch\?(?:.*&)?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/ + ) + if (youtubeMatch) { + return { url: `https://www.youtube.com/embed/${youtubeMatch[1]}`, type: 'iframe' } + } + } + + if (hostMatches(host, 'vimeo.com')) { + const vimeoMatch = url.match(/vimeo\.com\/(\d+)/) + if (vimeoMatch) { + return { url: `https://player.vimeo.com/video/${vimeoMatch[1]}`, type: 'iframe' } + } + } + + if (hostMatches(host, 'dailymotion.com')) { + const dailymotionMatch = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/) + if (dailymotionMatch) { + return { + url: `https://www.dailymotion.com/embed/video/${dailymotionMatch[1]}`, + type: 'iframe', + } + } + } + + if (hostMatches(host, 'twitch.tv')) { + const twitchVideoMatch = url.match(/twitch\.tv\/videos\/(\d+)/) + if (twitchVideoMatch) { + return { + url: `https://player.twitch.tv/?video=${twitchVideoMatch[1]}&parent=${getTwitchParent()}`, + type: 'iframe', + } + } + + const twitchClipMatch = + url.match(/clips\.twitch\.tv\/([a-zA-Z0-9_-]+)/) || + url.match(/twitch\.tv\/[^/]+\/clip\/([a-zA-Z0-9_-]+)/) + if (twitchClipMatch) { + return { + url: `https://clips.twitch.tv/embed?clip=${twitchClipMatch[1]}&parent=${getTwitchParent()}`, + type: 'iframe', + } + } + + const twitchChannelMatch = url.match(/twitch\.tv\/([a-zA-Z0-9_]+)(?:\/|$)/) + if (twitchChannelMatch && !url.includes('/videos/') && !url.includes('/clip/')) { + return { + url: `https://player.twitch.tv/?channel=${twitchChannelMatch[1]}&parent=${getTwitchParent()}`, + type: 'iframe', + } + } + } + + if (hostMatches(host, 'streamable.com')) { + const streamableMatch = url.match(/streamable\.com\/([a-zA-Z0-9]+)/) + if (streamableMatch) { + return { url: `https://streamable.com/e/${streamableMatch[1]}`, type: 'iframe' } + } + } + + if (hostMatches(host, 'wistia.com', 'wistia.net')) { + const wistiaMatch = url.match(/(?:wistia\.com|wistia\.net)\/(?:medias|embed)\/([a-zA-Z0-9]+)/) + if (wistiaMatch) { + return { url: `https://fast.wistia.net/embed/iframe/${wistiaMatch[1]}`, type: 'iframe' } + } + } + + if (hostMatches(host, 'tiktok.com')) { + const tiktokMatch = url.match(/tiktok\.com\/@[^/]+\/video\/(\d+)/) + if (tiktokMatch) { + return { + url: `https://www.tiktok.com/embed/v2/${tiktokMatch[1]}`, + type: 'iframe', + aspectRatio: '9/16', + } + } + } + + if (hostMatches(host, 'soundcloud.com')) { + const soundcloudMatch = url.match(/soundcloud\.com\/([a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)/) + if (soundcloudMatch) { + return { + url: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`, + type: 'iframe', + aspectRatio: '3/2', + } + } + } + + if (hostMatches(host, 'spotify.com')) { + const spotifyMatch = url.match( + /open\.spotify\.com\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/ + ) + if (spotifyMatch) { + const [, kind, id] = spotifyMatch + const aspectRatio = + kind === 'track' || kind === 'show' ? '3.7/1' : kind === 'episode' ? '2.5/1' : '2/3' + return { url: `https://open.spotify.com/embed/${kind}/${id}`, type: 'iframe', aspectRatio } + } + } + + if (hostMatches(host, 'apple.com')) { + const appleMusicSongMatch = url.match(/music\.apple\.com\/([a-z]{2})\/song\/[^/]+\/(\d+)/) + if (appleMusicSongMatch) { + const [, country, songId] = appleMusicSongMatch + return { + url: `https://embed.music.apple.com/${country}/song/${songId}`, + type: 'iframe', + aspectRatio: '3/2', + } + } + + const appleMusicAlbumMatch = url.match( + /music\.apple\.com\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)/ + ) + if (appleMusicAlbumMatch) { + const [, country, albumId] = appleMusicAlbumMatch + return { + url: `https://embed.music.apple.com/${country}/album/${albumId}`, + type: 'iframe', + aspectRatio: '2/3', + } + } + + const appleMusicPlaylistMatch = url.match( + /music\.apple\.com\/([a-z]{2})\/playlist\/[^/]+\/(pl\.[a-zA-Z0-9]+)/ + ) + if (appleMusicPlaylistMatch) { + const [, country, playlistId] = appleMusicPlaylistMatch + return { + url: `https://embed.music.apple.com/${country}/playlist/${playlistId}`, + type: 'iframe', + aspectRatio: '2/3', + } + } + } + + if (hostMatches(host, 'loom.com')) { + const loomMatch = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/) + if (loomMatch) { + return { url: `https://www.loom.com/embed/${loomMatch[1]}`, type: 'iframe' } + } + } + + if (hostMatches(host, 'facebook.com', 'fb.watch')) { + const facebookVideoMatch = + url.match(/facebook\.com\/.*\/videos\/(\d+)/) || url.match(/fb\.watch\/([a-zA-Z0-9_-]+)/) + if (facebookVideoMatch) { + return { + url: `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url)}&show_text=false`, + type: 'iframe', + } + } + } + + if (hostMatches(host, 'instagram.com')) { + const instagramReelMatch = url.match(/instagram\.com\/reel\/([a-zA-Z0-9_-]+)/) + if (instagramReelMatch) { + return { + url: `https://www.instagram.com/reel/${instagramReelMatch[1]}/embed`, + type: 'iframe', + aspectRatio: '9/16', + } + } + + const instagramPostMatch = url.match(/instagram\.com\/p\/([a-zA-Z0-9_-]+)/) + if (instagramPostMatch) { + return { + url: `https://www.instagram.com/p/${instagramPostMatch[1]}/embed`, + type: 'iframe', + aspectRatio: '4/5', + } + } + } + + if (hostMatches(host, 'twitter.com', 'x.com')) { + const twitterMatch = url.match(/(?:twitter\.com|x\.com)\/[^/]+\/status\/(\d+)/) + if (twitterMatch) { + return { + url: `https://platform.twitter.com/embed/Tweet.html?id=${twitterMatch[1]}`, + type: 'iframe', + aspectRatio: '3/4', + } + } + } + + if (hostMatches(host, 'rumble.com')) { + const rumbleMatch = + url.match(/rumble\.com\/embed\/([a-zA-Z0-9]+)/) || url.match(/rumble\.com\/([a-zA-Z0-9]+)-/) + if (rumbleMatch) { + return { url: `https://rumble.com/embed/${rumbleMatch[1]}/`, type: 'iframe' } + } + } + + if (hostMatches(host, 'bilibili.com')) { + const bilibiliMatch = url.match(/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/) + if (bilibiliMatch) { + return { + url: `https://player.bilibili.com/player.html?bvid=${bilibiliMatch[1]}&high_quality=1`, + type: 'iframe', + } + } + } + + if (hostMatches(host, 'vidyard.com')) { + const vidyardMatch = url.match(/(?:vidyard\.com|share\.vidyard\.com)\/watch\/([a-zA-Z0-9]+)/) + if (vidyardMatch) { + return { url: `https://play.vidyard.com/${vidyardMatch[1]}`, type: 'iframe' } + } + } + + if (hostMatches(host, 'cloudflarestream.com', 'videodelivery.net')) { + const cfStreamMatch = + url.match(/cloudflarestream\.com\/([a-zA-Z0-9]+)/) || + url.match(/videodelivery\.net\/([a-zA-Z0-9]+)/) + if (cfStreamMatch) { + return { url: `https://iframe.cloudflarestream.com/${cfStreamMatch[1]}`, type: 'iframe' } + } + } + + if (hostMatches(host, 'mixcloud.com')) { + const mixcloudMatch = url.match(/mixcloud\.com\/([^/]+\/[^/]+)/) + if (mixcloudMatch) { + return { + url: `https://www.mixcloud.com/widget/iframe/?feed=%2F${encodeURIComponent(mixcloudMatch[1])}%2F&hide_cover=1`, + type: 'iframe', + aspectRatio: '2/1', + } + } + } + + if (hostMatches(host, 'google.com')) { + const googleDriveMatch = url.match(/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/) + if (googleDriveMatch) { + return { + url: `https://drive.google.com/file/d/${googleDriveMatch[1]}/preview`, + type: 'iframe', + } + } + } + + if (parsed && hostMatches(host, 'dropbox.com')) { + const dropboxDirectVideoUrl = toDropboxDirectVideoUrl(parsed) + if (dropboxDirectVideoUrl) { + return { url: dropboxDirectVideoUrl, type: 'video' } + } + } + + if (hostMatches(host, 'tenor.com')) { + const tenorMatch = url.match(/tenor\.com\/view\/[^/]+-(\d+)/) + if (tenorMatch) { + return { url: `https://tenor.com/embed/${tenorMatch[1]}`, type: 'iframe', aspectRatio: '1/1' } + } + } + + if (hostMatches(host, 'giphy.com')) { + const giphyMatch = url.match(/giphy\.com\/(?:gifs|embed)\/(?:.*-)?([a-zA-Z0-9]+)/) + if (giphyMatch) { + return { url: `https://giphy.com/embed/${giphyMatch[1]}`, type: 'iframe', aspectRatio: '1/1' } + } + } + + if (/\.(mp4|webm|ogg|mov)(\?|$)/i.test(url)) { + return { url, type: 'video' } + } + + if (/\.(mp3|wav|m4a|aac)(\?|$)/i.test(url)) { + return { url, type: 'audio' } + } + + return null +} diff --git a/packages/workflow-renderer/package.json b/packages/workflow-renderer/package.json index 64f08ccb7f7..2fbefefdbbe 100644 --- a/packages/workflow-renderer/package.json +++ b/packages/workflow-renderer/package.json @@ -27,6 +27,7 @@ }, "peerDependencies": { "@sim/emcn": "workspace:*", + "@sim/utils": "workspace:*", "lucide-react": ">=0.479.0", "react": "^19", "reactflow": "^11.11.4", @@ -36,6 +37,7 @@ "devDependencies": { "@sim/emcn": "workspace:*", "@sim/tsconfig": "workspace:*", + "@sim/utils": "workspace:*", "@types/react": "^19", "lucide-react": "^0.479.0", "react": "19.2.4", diff --git a/packages/workflow-renderer/src/note/note-block-view.tsx b/packages/workflow-renderer/src/note/note-block-view.tsx index 70f6be2fbbb..4b0a3e50159 100644 --- a/packages/workflow-renderer/src/note/note-block-view.tsx +++ b/packages/workflow-renderer/src/note/note-block-view.tsx @@ -3,307 +3,11 @@ import remarkBreaks from 'remark-breaks' import { Streamdown } from 'streamdown' import 'streamdown/styles.css' import { cn, handleKeyboardActivation } from '@sim/emcn' - -type EmbedInfo = { - url: string - type: 'iframe' | 'video' | 'audio' - aspectRatio?: string -} +import { getEmbedInfo } from '@sim/utils/media-embed' const EMBED_SCALE = 0.78 const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%` -function getTwitchParent(): string { - return typeof window !== 'undefined' ? window.location.hostname : 'localhost' -} - -/** Parse a URL, tolerating scheme-less inputs (https is assumed). Returns null if unparseable. */ -function parseUrl(url: string): URL | null { - for (const candidate of [url, `https://${url}`]) { - try { - return new URL(candidate) - } catch {} - } - return null -} - -/** - * Resolve a Dropbox share link to a direct, embeddable video URL. Accepts only URLs - * whose host is `dropbox.com` or a `*.dropbox.com` subdomain (so attacker-controlled - * hosts like `dropbox.com.evil.com` are rejected), then rewrites the host to - * `dl.dropboxusercontent.com` so the file streams as media. Returns null for any - * non-Dropbox host or non-video path. - */ -function getDropboxDirectVideoUrl(url: string): string | null { - const parsed = parseUrl(url) - if (!parsed) return null - const host = parsed.hostname.toLowerCase() - if (host !== 'dropbox.com' && !host.endsWith('.dropbox.com')) return null - if (!/\.(mp4|mov|webm)$/i.test(parsed.pathname)) return null - parsed.hostname = 'dl.dropboxusercontent.com' - parsed.searchParams.delete('dl') - return parsed.toString() -} - -/** - * Get embed info for supported media platforms - */ -function getEmbedInfo(url: string): EmbedInfo | null { - const youtubeMatch = url.match( - /(?:youtube\.com\/watch\?(?:.*&)?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/ - ) - if (youtubeMatch) { - return { url: `https://www.youtube.com/embed/${youtubeMatch[1]}`, type: 'iframe' } - } - - const vimeoMatch = url.match(/vimeo\.com\/(\d+)/) - if (vimeoMatch) { - return { url: `https://player.vimeo.com/video/${vimeoMatch[1]}`, type: 'iframe' } - } - - const dailymotionMatch = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/) - if (dailymotionMatch) { - return { url: `https://www.dailymotion.com/embed/video/${dailymotionMatch[1]}`, type: 'iframe' } - } - - const twitchVideoMatch = url.match(/twitch\.tv\/videos\/(\d+)/) - if (twitchVideoMatch) { - return { - url: `https://player.twitch.tv/?video=${twitchVideoMatch[1]}&parent=${getTwitchParent()}`, - type: 'iframe', - } - } - - const twitchChannelMatch = url.match(/twitch\.tv\/([a-zA-Z0-9_]+)(?:\/|$)/) - if (twitchChannelMatch && !url.includes('/videos/') && !url.includes('/clip/')) { - return { - url: `https://player.twitch.tv/?channel=${twitchChannelMatch[1]}&parent=${getTwitchParent()}`, - type: 'iframe', - } - } - - const streamableMatch = url.match(/streamable\.com\/([a-zA-Z0-9]+)/) - if (streamableMatch) { - return { url: `https://streamable.com/e/${streamableMatch[1]}`, type: 'iframe' } - } - - const wistiaMatch = url.match(/(?:wistia\.com|wistia\.net)\/(?:medias|embed)\/([a-zA-Z0-9]+)/) - if (wistiaMatch) { - return { url: `https://fast.wistia.net/embed/iframe/${wistiaMatch[1]}`, type: 'iframe' } - } - - const tiktokMatch = url.match(/tiktok\.com\/@[^/]+\/video\/(\d+)/) - if (tiktokMatch) { - return { - url: `https://www.tiktok.com/embed/v2/${tiktokMatch[1]}`, - type: 'iframe', - aspectRatio: '9/16', - } - } - - const soundcloudMatch = url.match(/soundcloud\.com\/([a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)/) - if (soundcloudMatch) { - return { - url: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`, - type: 'iframe', - aspectRatio: '3/2', - } - } - - const spotifyTrackMatch = url.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/) - if (spotifyTrackMatch) { - return { - url: `https://open.spotify.com/embed/track/${spotifyTrackMatch[1]}`, - type: 'iframe', - aspectRatio: '3.7/1', - } - } - - const spotifyAlbumMatch = url.match(/open\.spotify\.com\/album\/([a-zA-Z0-9]+)/) - if (spotifyAlbumMatch) { - return { - url: `https://open.spotify.com/embed/album/${spotifyAlbumMatch[1]}`, - type: 'iframe', - aspectRatio: '2/3', - } - } - - const spotifyPlaylistMatch = url.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/) - if (spotifyPlaylistMatch) { - return { - url: `https://open.spotify.com/embed/playlist/${spotifyPlaylistMatch[1]}`, - type: 'iframe', - aspectRatio: '2/3', - } - } - - const spotifyEpisodeMatch = url.match(/open\.spotify\.com\/episode\/([a-zA-Z0-9]+)/) - if (spotifyEpisodeMatch) { - return { - url: `https://open.spotify.com/embed/episode/${spotifyEpisodeMatch[1]}`, - type: 'iframe', - aspectRatio: '2.5/1', - } - } - - const spotifyShowMatch = url.match(/open\.spotify\.com\/show\/([a-zA-Z0-9]+)/) - if (spotifyShowMatch) { - return { - url: `https://open.spotify.com/embed/show/${spotifyShowMatch[1]}`, - type: 'iframe', - aspectRatio: '3.7/1', - } - } - - const appleMusicSongMatch = url.match(/music\.apple\.com\/([a-z]{2})\/song\/[^/]+\/(\d+)/) - if (appleMusicSongMatch) { - const [, country, songId] = appleMusicSongMatch - return { - url: `https://embed.music.apple.com/${country}/song/${songId}`, - type: 'iframe', - aspectRatio: '3/2', - } - } - - const appleMusicAlbumMatch = url.match(/music\.apple\.com\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)/) - if (appleMusicAlbumMatch) { - const [, country, albumId] = appleMusicAlbumMatch - return { - url: `https://embed.music.apple.com/${country}/album/${albumId}`, - type: 'iframe', - aspectRatio: '2/3', - } - } - - const appleMusicPlaylistMatch = url.match( - /music\.apple\.com\/([a-z]{2})\/playlist\/[^/]+\/(pl\.[a-zA-Z0-9]+)/ - ) - if (appleMusicPlaylistMatch) { - const [, country, playlistId] = appleMusicPlaylistMatch - return { - url: `https://embed.music.apple.com/${country}/playlist/${playlistId}`, - type: 'iframe', - aspectRatio: '2/3', - } - } - - const loomMatch = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/) - if (loomMatch) { - return { url: `https://www.loom.com/embed/${loomMatch[1]}`, type: 'iframe' } - } - - const facebookVideoMatch = - url.match(/facebook\.com\/.*\/videos\/(\d+)/) || url.match(/fb\.watch\/([a-zA-Z0-9_-]+)/) - if (facebookVideoMatch) { - return { - url: `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url)}&show_text=false`, - type: 'iframe', - } - } - - const instagramReelMatch = url.match(/instagram\.com\/reel\/([a-zA-Z0-9_-]+)/) - if (instagramReelMatch) { - return { - url: `https://www.instagram.com/reel/${instagramReelMatch[1]}/embed`, - type: 'iframe', - aspectRatio: '9/16', - } - } - - const instagramPostMatch = url.match(/instagram\.com\/p\/([a-zA-Z0-9_-]+)/) - if (instagramPostMatch) { - return { - url: `https://www.instagram.com/p/${instagramPostMatch[1]}/embed`, - type: 'iframe', - aspectRatio: '4/5', - } - } - - const twitterMatch = url.match(/(?:twitter\.com|x\.com)\/[^/]+\/status\/(\d+)/) - if (twitterMatch) { - return { - url: `https://platform.twitter.com/embed/Tweet.html?id=${twitterMatch[1]}`, - type: 'iframe', - aspectRatio: '3/4', - } - } - - const rumbleMatch = - url.match(/rumble\.com\/embed\/([a-zA-Z0-9]+)/) || url.match(/rumble\.com\/([a-zA-Z0-9]+)-/) - if (rumbleMatch) { - return { url: `https://rumble.com/embed/${rumbleMatch[1]}/`, type: 'iframe' } - } - - const bilibiliMatch = url.match(/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/) - if (bilibiliMatch) { - return { - url: `https://player.bilibili.com/player.html?bvid=${bilibiliMatch[1]}&high_quality=1`, - type: 'iframe', - } - } - - const vidyardMatch = url.match(/(?:vidyard\.com|share\.vidyard\.com)\/watch\/([a-zA-Z0-9]+)/) - if (vidyardMatch) { - return { url: `https://play.vidyard.com/${vidyardMatch[1]}`, type: 'iframe' } - } - - const cfStreamMatch = - url.match(/cloudflarestream\.com\/([a-zA-Z0-9]+)/) || - url.match(/videodelivery\.net\/([a-zA-Z0-9]+)/) - if (cfStreamMatch) { - return { url: `https://iframe.cloudflarestream.com/${cfStreamMatch[1]}`, type: 'iframe' } - } - - const twitchClipMatch = - url.match(/clips\.twitch\.tv\/([a-zA-Z0-9_-]+)/) || - url.match(/twitch\.tv\/[^/]+\/clip\/([a-zA-Z0-9_-]+)/) - if (twitchClipMatch) { - return { - url: `https://clips.twitch.tv/embed?clip=${twitchClipMatch[1]}&parent=${getTwitchParent()}`, - type: 'iframe', - } - } - - const mixcloudMatch = url.match(/mixcloud\.com\/([^/]+\/[^/]+)/) - if (mixcloudMatch) { - return { - url: `https://www.mixcloud.com/widget/iframe/?feed=%2F${encodeURIComponent(mixcloudMatch[1])}%2F&hide_cover=1`, - type: 'iframe', - aspectRatio: '2/1', - } - } - - const googleDriveMatch = url.match(/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/) - if (googleDriveMatch) { - return { url: `https://drive.google.com/file/d/${googleDriveMatch[1]}/preview`, type: 'iframe' } - } - - const dropboxDirectVideoUrl = getDropboxDirectVideoUrl(url) - if (dropboxDirectVideoUrl) { - return { url: dropboxDirectVideoUrl, type: 'video' } - } - - const tenorMatch = url.match(/tenor\.com\/view\/[^/]+-(\d+)/) - if (tenorMatch) { - return { url: `https://tenor.com/embed/${tenorMatch[1]}`, type: 'iframe', aspectRatio: '1/1' } - } - - const giphyMatch = url.match(/giphy\.com\/(?:gifs|embed)\/(?:.*-)?([a-zA-Z0-9]+)/) - if (giphyMatch) { - return { url: `https://giphy.com/embed/${giphyMatch[1]}`, type: 'iframe', aspectRatio: '1/1' } - } - - if (/\.(mp4|webm|ogg|mov)(\?|$)/i.test(url)) { - return { url, type: 'video' } - } - - if (/\.(mp3|wav|m4a|aac)(\?|$)/i.test(url)) { - return { url, type: 'audio' } - } - - return null -} - /** * Compact markdown renderer for note blocks with tight spacing */ From d0aed14a0f40cf6b4c67a72b2c571a7755193c6c Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 30 Jun 2026 11:39:11 -0700 Subject: [PATCH 04/27] feat(integrations): wave-4 tool-depth (Slack/Asana/Jira/Google Docs/Trello/Monday) + context.dev validation (#5289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(context_dev): validation pass — add search numResults/country, accuracy fixes Comprehensive /validate-integration of all 22 context.dev tools against the live API docs found the integration clean (no correctness bugs). Applied the actionable items: - search: expose numResults (10-100) + country inputs (API supported them; users were silently capped at 10 results) - accuracy: scrape_html type description (+doc/docx), map meta description (+sitemapsSkipped), brand links description (+contact) - robustness: trim string query values in appendParam * feat(integrations): wave-4 tool-depth — Slack, Asana, Jira, Google Docs, Trello, Monday Deepen six existing blocks with 38 new tools, no new OAuth scopes (all under already-granted scopes), additive/backwards-compatible: - Slack (7): schedule/list/delete scheduled messages; archive/rename/set-topic/set-purpose conversation - Asana (8): create/get project, list workspaces, create subtask, delete task, add followers, create/list sections (via internal routes + contracts) - Jira (5): list/get project, get transitions, list issue types, get fields - Google Docs (6): delete content range, named ranges, paragraph bullets, update paragraph style (documents.batchUpdate) - Trello (7): create board/list, get board/card, add checklist/label/member - Monday (5): change column value, create board/column, get groups, duplicate item Route baseline 873->881 for the 8 new Asana internal routes. * fix(integrations): wave-4 validation pass — fix alignment enum, GraphQL input-object, scope/UI gaps Comprehensive /validate-integration of all 6 modified integrations (existing + new tools) vs live API docs. Fixes: - google_docs: CRITICAL alignment enum LEFT/RIGHT/JUSTIFY -> API enum START/END/JUSTIFIED (mapped); namedStyleType 'unchanged' option; 'zero-based' index wording - monday: CRITICAL search_items columns now emits GraphQL input-object with unquoted keys (was always failing the non-cursor branch) - slack: schedule_message DMs via user-id-as-channel; add channels:manage/groups:write/reactions:read scope descriptions; nextCursor optional - jira: list_projects expand=lead so lead outputs populate (was always null) - trello: get_actions limit now applies to the card path too - asana: add missing 'completed' + 'projects' subBlocks (were unsettable in UI); request permalink_url via opt_fields on create routes * fix(integrations): clamp context.dev search bounds; precise Google Docs index wording - context_dev/search: clamp numResults to the documented 10-100 range; normalize country to trimmed uppercase - google_docs: replace ambiguous '1-based'/'zero-based' index wording with the concrete fact (the document body starts at index 1), matching buildInsertLocation (index<1 appends) and buildContentRange * fix(integrations): validate context.dev country (ISO-2); regenerate google_docs docs - context_dev/search: reject non-2-letter country values with a clear error instead of forwarding them - docs: regenerate google_docs.mdx so the public index-contract wording matches the updated tool descriptions (body starts at index 1) * fix(asana): omit completed unless explicitly set (don't send false on unchecked) The new completion checkbox mapped an unchecked/untouched state to completed:false, which made update_task silently un-complete tasks and search_tasks filter to incomplete. Now only sends completed when the box is checked (undefined otherwise). * fix(slack): expose Destination toggle for schedule_message so DM scheduling is reachable The mapper already routes schedule_message DMs (user-id-as-channel); add schedule_message to the destinationType condition so users can deliberately choose Channel vs DM instead of it only triggering via leftover state. --- .../content/docs/en/integrations/asana.mdx | 183 ++++++++++ .../docs/en/integrations/context_dev.mdx | 16 +- .../docs/en/integrations/google_docs.mdx | 152 +++++++- .../content/docs/en/integrations/jira.mdx | 149 ++++++++ .../content/docs/en/integrations/monday.mdx | 140 ++++++++ .../content/docs/en/integrations/slack.mdx | 183 ++++++++++ .../content/docs/en/integrations/trello.mdx | 157 +++++++++ .../api/tools/asana/add-followers/route.ts | 103 ++++++ .../api/tools/asana/create-project/route.ts | 103 ++++++ .../api/tools/asana/create-section/route.ts | 92 +++++ .../api/tools/asana/create-subtask/route.ts | 106 ++++++ .../app/api/tools/asana/create-task/route.ts | 3 +- .../app/api/tools/asana/delete-task/route.ts | 81 +++++ .../app/api/tools/asana/get-project/route.ts | 92 +++++ .../api/tools/asana/list-sections/route.ts | 93 +++++ .../api/tools/asana/list-workspaces/route.ts | 87 +++++ apps/sim/blocks/blocks/asana.ts | 247 ++++++++++++- apps/sim/blocks/blocks/context_dev.ts | 18 + apps/sim/blocks/blocks/google_docs.ts | 140 +++++++- apps/sim/blocks/blocks/jira.ts | 124 ++++++- apps/sim/blocks/blocks/monday.ts | 238 ++++++++++++- apps/sim/blocks/blocks/slack.ts | 248 ++++++++++++- apps/sim/blocks/blocks/trello.ts | 328 +++++++++++++++++- apps/sim/lib/api/contracts/tools/asana.ts | 188 ++++++++++ apps/sim/lib/integrations/integrations.json | 166 ++++++++- apps/sim/lib/oauth/utils.ts | 3 + apps/sim/tools/asana/add_followers.ts | 87 +++++ apps/sim/tools/asana/create_project.ts | 91 +++++ apps/sim/tools/asana/create_section.ts | 76 ++++ apps/sim/tools/asana/create_subtask.ts | 109 ++++++ apps/sim/tools/asana/delete_task.ts | 68 ++++ apps/sim/tools/asana/get_project.ts | 74 ++++ apps/sim/tools/asana/index.ts | 16 + apps/sim/tools/asana/list_sections.ts | 79 +++++ apps/sim/tools/asana/list_workspaces.ts | 74 ++++ apps/sim/tools/asana/types.ts | 118 +++++++ apps/sim/tools/context_dev/map.ts | 3 +- apps/sim/tools/context_dev/scrape_html.ts | 3 +- apps/sim/tools/context_dev/search.ts | 26 ++ apps/sim/tools/context_dev/types.ts | 7 +- apps/sim/tools/context_dev/utils.ts | 4 +- .../tools/google_docs/create-named-range.ts | 124 +++++++ .../google_docs/create-paragraph-bullets.ts | 144 ++++++++ .../tools/google_docs/delete-content-range.ts | 109 ++++++ .../tools/google_docs/delete-named-range.ts | 110 ++++++ .../google_docs/delete-paragraph-bullets.ts | 109 ++++++ apps/sim/tools/google_docs/index.ts | 12 + apps/sim/tools/google_docs/insert-image.ts | 2 +- .../tools/google_docs/insert-page-break.ts | 2 +- apps/sim/tools/google_docs/insert-table.ts | 2 +- apps/sim/tools/google_docs/insert-text.ts | 2 +- apps/sim/tools/google_docs/types.ts | 54 +++ .../google_docs/update-paragraph-style.ts | 180 ++++++++++ .../tools/google_docs/update-text-style.ts | 3 +- apps/sim/tools/google_docs/utils.ts | 21 ++ apps/sim/tools/jira/get_fields.ts | 153 ++++++++ apps/sim/tools/jira/get_project.ts | 173 +++++++++ apps/sim/tools/jira/get_transitions.ts | 166 +++++++++ apps/sim/tools/jira/index.ts | 10 + apps/sim/tools/jira/list_issue_types.ts | 150 ++++++++ apps/sim/tools/jira/list_projects.ts | 208 +++++++++++ apps/sim/tools/jira/types.ts | 134 +++++++ apps/sim/tools/monday/change_column_value.ts | 160 +++++++++ apps/sim/tools/monday/create_board.ts | 134 +++++++ apps/sim/tools/monday/create_column.ts | 159 +++++++++ apps/sim/tools/monday/duplicate_item.ts | 142 ++++++++ apps/sim/tools/monday/get_groups.ts | 94 +++++ apps/sim/tools/monday/index.ts | 5 + apps/sim/tools/monday/search_items.ts | 29 +- apps/sim/tools/monday/types.ts | 70 ++++ apps/sim/tools/monday/utils.ts | 18 + apps/sim/tools/registry.ts | 76 ++++ apps/sim/tools/slack/archive_conversation.ts | 101 ++++++ .../tools/slack/delete_scheduled_message.ts | 104 ++++++ apps/sim/tools/slack/index.ts | 14 + .../tools/slack/list_scheduled_messages.ts | 145 ++++++++ apps/sim/tools/slack/rename_conversation.ts | 133 +++++++ apps/sim/tools/slack/schedule_message.ts | 138 ++++++++ .../tools/slack/set_conversation_purpose.ts | 105 ++++++ .../sim/tools/slack/set_conversation_topic.ts | 119 +++++++ apps/sim/tools/slack/types.ts | 114 ++++++ apps/sim/tools/trello/add_checklist.ts | 136 ++++++++ apps/sim/tools/trello/add_label.ts | 99 ++++++ apps/sim/tools/trello/add_member.ts | 99 ++++++ apps/sim/tools/trello/create_board.ts | 143 ++++++++ apps/sim/tools/trello/create_list.ts | 130 +++++++ apps/sim/tools/trello/get_actions.ts | 2 +- apps/sim/tools/trello/get_board.ts | 116 +++++++ apps/sim/tools/trello/get_card.ts | 148 ++++++++ apps/sim/tools/trello/index.ts | 14 + apps/sim/tools/trello/shared.ts | 33 +- apps/sim/tools/trello/types.ts | 111 +++++- scripts/check-api-validation-contracts.ts | 4 +- 93 files changed, 8971 insertions(+), 67 deletions(-) create mode 100644 apps/sim/app/api/tools/asana/add-followers/route.ts create mode 100644 apps/sim/app/api/tools/asana/create-project/route.ts create mode 100644 apps/sim/app/api/tools/asana/create-section/route.ts create mode 100644 apps/sim/app/api/tools/asana/create-subtask/route.ts create mode 100644 apps/sim/app/api/tools/asana/delete-task/route.ts create mode 100644 apps/sim/app/api/tools/asana/get-project/route.ts create mode 100644 apps/sim/app/api/tools/asana/list-sections/route.ts create mode 100644 apps/sim/app/api/tools/asana/list-workspaces/route.ts create mode 100644 apps/sim/tools/asana/add_followers.ts create mode 100644 apps/sim/tools/asana/create_project.ts create mode 100644 apps/sim/tools/asana/create_section.ts create mode 100644 apps/sim/tools/asana/create_subtask.ts create mode 100644 apps/sim/tools/asana/delete_task.ts create mode 100644 apps/sim/tools/asana/get_project.ts create mode 100644 apps/sim/tools/asana/list_sections.ts create mode 100644 apps/sim/tools/asana/list_workspaces.ts create mode 100644 apps/sim/tools/google_docs/create-named-range.ts create mode 100644 apps/sim/tools/google_docs/create-paragraph-bullets.ts create mode 100644 apps/sim/tools/google_docs/delete-content-range.ts create mode 100644 apps/sim/tools/google_docs/delete-named-range.ts create mode 100644 apps/sim/tools/google_docs/delete-paragraph-bullets.ts create mode 100644 apps/sim/tools/google_docs/update-paragraph-style.ts create mode 100644 apps/sim/tools/jira/get_fields.ts create mode 100644 apps/sim/tools/jira/get_project.ts create mode 100644 apps/sim/tools/jira/get_transitions.ts create mode 100644 apps/sim/tools/jira/list_issue_types.ts create mode 100644 apps/sim/tools/jira/list_projects.ts create mode 100644 apps/sim/tools/monday/change_column_value.ts create mode 100644 apps/sim/tools/monday/create_board.ts create mode 100644 apps/sim/tools/monday/create_column.ts create mode 100644 apps/sim/tools/monday/duplicate_item.ts create mode 100644 apps/sim/tools/monday/get_groups.ts create mode 100644 apps/sim/tools/slack/archive_conversation.ts create mode 100644 apps/sim/tools/slack/delete_scheduled_message.ts create mode 100644 apps/sim/tools/slack/list_scheduled_messages.ts create mode 100644 apps/sim/tools/slack/rename_conversation.ts create mode 100644 apps/sim/tools/slack/schedule_message.ts create mode 100644 apps/sim/tools/slack/set_conversation_purpose.ts create mode 100644 apps/sim/tools/slack/set_conversation_topic.ts create mode 100644 apps/sim/tools/trello/add_checklist.ts create mode 100644 apps/sim/tools/trello/add_label.ts create mode 100644 apps/sim/tools/trello/add_member.ts create mode 100644 apps/sim/tools/trello/create_board.ts create mode 100644 apps/sim/tools/trello/create_list.ts create mode 100644 apps/sim/tools/trello/get_board.ts create mode 100644 apps/sim/tools/trello/get_card.ts diff --git a/apps/docs/content/docs/en/integrations/asana.mdx b/apps/docs/content/docs/en/integrations/asana.mdx index bd9a332e6d0..ac8dd885ecc 100644 --- a/apps/docs/content/docs/en/integrations/asana.mdx +++ b/apps/docs/content/docs/en/integrations/asana.mdx @@ -209,4 +209,187 @@ Add a comment (story) to an Asana task | ↳ `gid` | string | Author GID | | ↳ `name` | string | Author name | +### `asana_create_subtask` + +Create a subtask under an existing Asana task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskGid` | string | Yes | GID of the parent Asana task \(numeric string\) | +| `name` | string | Yes | Name of the subtask | +| `notes` | string | No | Notes or description for the subtask | +| `assignee` | string | No | User GID to assign the subtask to | +| `due_on` | string | No | Due date in YYYY-MM-DD format | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `gid` | string | Subtask globally unique identifier | +| `name` | string | Subtask name | +| `notes` | string | Subtask notes or description | +| `completed` | boolean | Whether the subtask is completed | +| `created_at` | string | Subtask creation timestamp | +| `permalink_url` | string | URL to the subtask in Asana | + +### `asana_delete_task` + +Delete an Asana task by its GID (moves it to the trash) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskGid` | string | Yes | GID of the Asana task to delete \(numeric string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `gid` | string | GID of the deleted task | +| `deleted` | boolean | Whether the task was deleted | + +### `asana_add_followers` + +Add one or more followers to an Asana task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskGid` | string | Yes | GID of the Asana task \(numeric string\) | +| `followers` | array | Yes | Array of user GIDs to add as followers to the task | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `gid` | string | Task globally unique identifier | +| `name` | string | Task name | +| `followers` | array | Current followers on the task after the update | +| ↳ `gid` | string | Follower GID | +| ↳ `name` | string | Follower name | + +### `asana_create_project` + +Create a new project in an Asana workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspace` | string | Yes | Asana workspace GID \(numeric string\) where the project will be created | +| `name` | string | Yes | Name of the project | +| `notes` | string | No | Notes or description for the project | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `gid` | string | Project globally unique identifier | +| `name` | string | Project name | +| `notes` | string | Project notes or description | +| `archived` | boolean | Whether the project is archived | +| `color` | string | Project color | +| `created_at` | string | Project creation timestamp | +| `modified_at` | string | Project last modified timestamp | +| `permalink_url` | string | URL to the project in Asana | + +### `asana_get_project` + +Retrieve a single Asana project by its GID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectGid` | string | Yes | Asana project GID \(numeric string\) to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `gid` | string | Project globally unique identifier | +| `name` | string | Project name | +| `notes` | string | Project notes or description | +| `archived` | boolean | Whether the project is archived | +| `color` | string | Project color | +| `created_at` | string | Project creation timestamp | +| `modified_at` | string | Project last modified timestamp | +| `permalink_url` | string | URL to the project in Asana | + +### `asana_list_workspaces` + +List all Asana workspaces and organizations the authenticated user belongs to + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `workspaces` | array | Array of workspaces | +| ↳ `gid` | string | Workspace GID | +| ↳ `name` | string | Workspace name | +| ↳ `resource_type` | string | Resource type \(workspace\) | + +### `asana_create_section` + +Create a new section in an Asana project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectGid` | string | Yes | GID of the Asana project \(numeric string\) to add the section to | +| `name` | string | Yes | Name of the section | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `gid` | string | Section globally unique identifier | +| `name` | string | Section name | +| `created_at` | string | Section creation timestamp | + +### `asana_list_sections` + +List all sections in an Asana project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectGid` | string | Yes | GID of the Asana project \(numeric string\) to list sections from | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `sections` | array | Array of sections in the project | +| ↳ `gid` | string | Section GID | +| ↳ `name` | string | Section name | +| ↳ `resource_type` | string | Resource type \(section\) | + diff --git a/apps/docs/content/docs/en/integrations/context_dev.mdx b/apps/docs/content/docs/en/integrations/context_dev.mdx index e2b5898af95..3cd1687a0fc 100644 --- a/apps/docs/content/docs/en/integrations/context_dev.mdx +++ b/apps/docs/content/docs/en/integrations/context_dev.mdx @@ -65,7 +65,7 @@ Scrape any URL and return the raw HTML content of the page. | --------- | ---- | ----------- | | `html` | string | Raw HTML content of the page | | `url` | string | The scraped URL | -| `type` | string | Detected content type \(html, xml, json, text, csv, markdown, svg, pdf\) | +| `type` | string | Detected content type \(html, xml, json, text, csv, markdown, svg, pdf, doc, docx\) | ### `context_dev_scrape_images` @@ -177,7 +177,7 @@ Build a sitemap of a domain and return every discovered page URL. | --------- | ---- | ----------- | | `domain` | string | The domain that was mapped | | `urls` | array | All page URLs discovered from the sitemap | -| `meta` | object | Sitemap discovery stats \(sitemapsDiscovered, sitemapsFetched, errors\) | +| `meta` | object | Sitemap discovery stats \(sitemapsDiscovered, sitemapsFetched, sitemapsSkipped, errors\) | ### `context_dev_search` @@ -191,6 +191,8 @@ Search the web with natural language and optionally scrape results to markdown. | `includeDomains` | array | No | Only return results from these domains | | `excludeDomains` | array | No | Exclude results from these domains | | `freshness` | string | No | Recency filter \(last_24_hours, last_week, last_month, last_year\) | +| `numResults` | number | No | Number of results to return \(10-100, default 10\) | +| `country` | string | No | Restrict results to a country \(ISO 3166-1 alpha-2 code, e.g. US\) | | `queryFanout` | boolean | No | Expand the query into parallel variants for broader coverage | | `markdownEnabled` | boolean | No | Scrape each result page to markdown \(default: false\) | | `timeoutMS` | number | No | Request timeout in milliseconds \(1000-300000\) | @@ -449,7 +451,7 @@ Retrieve brand data for a domain: logos, colors, backdrops, socials, address, an | ↳ `email` | string | Brand contact email | | ↳ `phone` | string | Brand contact phone | | ↳ `industries` | json | Industry taxonomy \(eic industry/subindustry pairs\) | -| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing\) | +| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing, contact\) | | ↳ `primary_language` | string | Primary language of the brand site | ### `context_dev_get_brand_by_name` @@ -488,7 +490,7 @@ Retrieve brand data by company name: logos, colors, socials, address, and indust | ↳ `email` | string | Brand contact email | | ↳ `phone` | string | Brand contact phone | | ↳ `industries` | json | Industry taxonomy \(eic industry/subindustry pairs\) | -| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing\) | +| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing, contact\) | | ↳ `primary_language` | string | Primary language of the brand site | ### `context_dev_get_brand_by_email` @@ -526,7 +528,7 @@ Retrieve brand data from a work email address. Free/disposable emails are reject | ↳ `email` | string | Brand contact email | | ↳ `phone` | string | Brand contact phone | | ↳ `industries` | json | Industry taxonomy \(eic industry/subindustry pairs\) | -| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing\) | +| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing, contact\) | | ↳ `primary_language` | string | Primary language of the brand site | ### `context_dev_get_brand_by_ticker` @@ -565,7 +567,7 @@ Retrieve brand data for a public company by its stock ticker symbol. | ↳ `email` | string | Brand contact email | | ↳ `phone` | string | Brand contact phone | | ↳ `industries` | json | Industry taxonomy \(eic industry/subindustry pairs\) | -| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing\) | +| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing, contact\) | | ↳ `primary_language` | string | Primary language of the brand site | ### `context_dev_get_brand_simplified` @@ -632,7 +634,7 @@ Identify the brand behind a raw bank/card transaction descriptor and return its | ↳ `email` | string | Brand contact email | | ↳ `phone` | string | Brand contact phone | | ↳ `industries` | json | Industry taxonomy \(eic industry/subindustry pairs\) | -| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing\) | +| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing, contact\) | | ↳ `primary_language` | string | Primary language of the brand site | ### `context_dev_prefetch_domain` diff --git a/apps/docs/content/docs/en/integrations/google_docs.mdx b/apps/docs/content/docs/en/integrations/google_docs.mdx index 9dc591ccb0a..1b8c137a1b7 100644 --- a/apps/docs/content/docs/en/integrations/google_docs.mdx +++ b/apps/docs/content/docs/en/integrations/google_docs.mdx @@ -110,7 +110,7 @@ Insert text at a specific index in a Google Docs document. When no index is prov | --------- | ---- | -------- | ----------- | | `documentId` | string | Yes | The ID of the document to insert text into | | `text` | string | Yes | The text to insert | -| `index` | number | No | The 1-based character index at which to insert the text. When omitted, text is appended to the end of the document. | +| `index` | number | No | The character index \(the document body starts at index 1\) at which to insert the text. When omitted, text is appended to the end of the document. | #### Output @@ -158,7 +158,7 @@ Insert an empty table with the given number of rows and columns into a Google Do | `documentId` | string | Yes | The ID of the document to insert the table into | | `rows` | number | Yes | The number of rows in the table | | `columns` | number | Yes | The number of columns in the table | -| `index` | number | No | The 1-based character index at which to insert the table. When omitted, the table is appended to the end of the document. | +| `index` | number | No | The character index \(the document body starts at index 1\) at which to insert the table. When omitted, the table is appended to the end of the document. | #### Output @@ -181,7 +181,7 @@ Insert an inline image from a public URL into a Google Docs document. The image | --------- | ---- | -------- | ----------- | | `documentId` | string | Yes | The ID of the document to insert the image into | | `imageUrl` | string | Yes | The publicly accessible URL of the image to insert | -| `index` | number | No | The 1-based character index at which to insert the image. When omitted, the image is appended to the end of the document. | +| `index` | number | No | The character index \(the document body starts at index 1\) at which to insert the image. When omitted, the image is appended to the end of the document. | | `width` | number | No | Optional image width in points \(PT\) | | `height` | number | No | Optional image height in points \(PT\) | @@ -205,7 +205,7 @@ Insert a page break into a Google Docs document. When no index is provided, the | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `documentId` | string | Yes | The ID of the document to insert the page break into | -| `index` | number | No | The 1-based character index at which to insert the page break. When omitted, the page break is appended to the end of the document. | +| `index` | number | No | The character index \(the document body starts at index 1\) at which to insert the page break. When omitted, the page break is appended to the end of the document. | #### Output @@ -227,7 +227,7 @@ Apply bold, italic, underline, and/or font size to a range of text in a Google D | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `documentId` | string | Yes | The ID of the document to update | -| `startIndex` | number | Yes | The 1-based start character index of the range to style \(inclusive\) | +| `startIndex` | number | Yes | The start character index \(the document body starts at index 1\) of the range to style \(inclusive\) | | `endIndex` | number | Yes | The end character index of the range to style \(exclusive\) | | `bold` | boolean | No | Whether to make the text bold | | `italic` | boolean | No | Whether to make the text italic | @@ -245,4 +245,146 @@ Apply bold, italic, underline, and/or font size to a range of text in a Google D | ↳ `mimeType` | string | Document MIME type | | ↳ `url` | string | Document URL | +### `google_docs_update_paragraph_style` + +Apply a named paragraph style (such as a heading or title) and/or alignment to the paragraphs overlapping a range of text in a Google Docs document, identified by its start and end character index. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `documentId` | string | Yes | The ID of the document to update | +| `startIndex` | number | Yes | The start character index \(the document body starts at index 1\) of the range to style \(inclusive\) | +| `endIndex` | number | Yes | The end character index of the range to style \(exclusive\) | +| `namedStyleType` | string | No | The named paragraph style to apply. One of: NORMAL_TEXT, TITLE, SUBTITLE, HEADING_1, HEADING_2, HEADING_3, HEADING_4, HEADING_5, HEADING_6. | +| `alignment` | string | No | The paragraph alignment to apply. One of: LEFT, CENTER, RIGHT, JUSTIFY. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `updatedContent` | boolean | Indicates if the paragraph style was applied successfully | +| `metadata` | json | Updated document metadata including ID, title, and URL | +| ↳ `documentId` | string | Google Docs document ID | +| ↳ `title` | string | Document title | +| ↳ `mimeType` | string | Document MIME type | +| ↳ `url` | string | Document URL | + +### `google_docs_create_paragraph_bullets` + +Add bulleted or numbered list formatting to the paragraphs overlapping a range of text in a Google Docs document, using a chosen bullet glyph preset. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `documentId` | string | Yes | The ID of the document to update | +| `startIndex` | number | Yes | The start character index \(the document body starts at index 1\) of the range to bullet \(inclusive\) | +| `endIndex` | number | Yes | The end character index of the range to bullet \(exclusive\) | +| `bulletPreset` | string | No | The bullet glyph preset to apply. Defaults to BULLET_DISC_CIRCLE_SQUARE. Examples: BULLET_DISC_CIRCLE_SQUARE, BULLET_CHECKBOX, NUMBERED_DECIMAL_ALPHA_ROMAN, NUMBERED_DECIMAL_NESTED. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `updatedContent` | boolean | Indicates if the bullets were applied successfully | +| `metadata` | json | Updated document metadata including ID, title, and URL | +| ↳ `documentId` | string | Google Docs document ID | +| ↳ `title` | string | Document title | +| ↳ `mimeType` | string | Document MIME type | +| ↳ `url` | string | Document URL | + +### `google_docs_delete_paragraph_bullets` + +Remove bullet or numbered list formatting from the paragraphs overlapping a range of text in a Google Docs document, identified by its start and end character index. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `documentId` | string | Yes | The ID of the document to update | +| `startIndex` | number | Yes | The start character index \(the document body starts at index 1\) of the range to clear bullets from \(inclusive\) | +| `endIndex` | number | Yes | The end character index of the range to clear bullets from \(exclusive\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `updatedContent` | boolean | Indicates if the bullets were removed successfully | +| `metadata` | json | Updated document metadata including ID, title, and URL | +| ↳ `documentId` | string | Google Docs document ID | +| ↳ `title` | string | Document title | +| ↳ `mimeType` | string | Document MIME type | +| ↳ `url` | string | Document URL | + +### `google_docs_delete_content_range` + +Delete all content between a start and end character index in a Google Docs document. The endIndex is exclusive and must be greater than the startIndex. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `documentId` | string | Yes | The ID of the document to delete content from | +| `startIndex` | number | Yes | The start character index \(the document body starts at index 1\) of the range to delete \(inclusive\) | +| `endIndex` | number | Yes | The end character index of the range to delete \(exclusive\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `updatedContent` | boolean | Indicates if the content range was deleted successfully | +| `metadata` | json | Updated document metadata including ID, title, and URL | +| ↳ `documentId` | string | Google Docs document ID | +| ↳ `title` | string | Document title | +| ↳ `mimeType` | string | Document MIME type | +| ↳ `url` | string | Document URL | + +### `google_docs_create_named_range` + +Create a named range over a span of content in a Google Docs document so it can be referenced or deleted later. The name may be 1-256 characters and need not be unique. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `documentId` | string | Yes | The ID of the document to update | +| `name` | string | Yes | The name of the range to create \(1-256 characters\) | +| `startIndex` | number | Yes | The start character index \(the document body starts at index 1\) of the range \(inclusive\) | +| `endIndex` | number | Yes | The end character index of the range \(exclusive\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `namedRangeId` | string | The ID of the created named range | +| `metadata` | json | Updated document metadata including ID, title, and URL | +| ↳ `documentId` | string | Google Docs document ID | +| ↳ `title` | string | Document title | +| ↳ `mimeType` | string | Document MIME type | +| ↳ `url` | string | Document URL | + +### `google_docs_delete_named_range` + +Delete one or more named ranges from a Google Docs document by their ID or by name. Provide exactly one of namedRangeId or name; deleting by name removes all ranges sharing that name. The content itself is not removed. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `documentId` | string | Yes | The ID of the document to update | +| `namedRangeId` | string | No | The ID of the named range to delete. Provide exactly one of namedRangeId or namedRangeName. | +| `namedRangeName` | string | No | The name of the named range\(s\) to delete. All ranges sharing this name are removed. Provide exactly one of namedRangeId or namedRangeName. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `updatedContent` | boolean | Indicates if the named range\(s\) were deleted successfully | +| `metadata` | json | Updated document metadata including ID, title, and URL | +| ↳ `documentId` | string | Google Docs document ID | +| ↳ `title` | string | Document title | +| ↳ `mimeType` | string | Document MIME type | +| ↳ `url` | string | Document URL | + diff --git a/apps/docs/content/docs/en/integrations/jira.mdx b/apps/docs/content/docs/en/integrations/jira.mdx index 86ba0172594..993f9f1d5e3 100644 --- a/apps/docs/content/docs/en/integrations/jira.mdx +++ b/apps/docs/content/docs/en/integrations/jira.mdx @@ -1046,6 +1046,155 @@ Search for Jira users by email address or display name. Returns matching users w | `startAt` | number | Pagination start index | | `maxResults` | number | Maximum results per page | +### `jira_list_projects` + +List Jira projects visible to the user, with optional name/key filtering and pagination. Returns each project with id, key, name, and type. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `query` | string | No | Filter projects by partial name or key match | +| `startAt` | number | No | The index of the first project to return \(for pagination, default: 0\) | +| `maxResults` | number | No | Maximum number of projects to return \(default: 50, max: 100\) | +| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | ISO 8601 timestamp of the operation | +| `projects` | array | Array of Jira projects | +| ↳ `id` | string | Project ID | +| ↳ `key` | string | Project key \(e.g., PROJ\) | +| ↳ `name` | string | Project name | +| ↳ `projectTypeKey` | string | Project type key \(e.g., software, service_desk, business\) | +| ↳ `simplified` | boolean | Whether the project is a simplified \(team-managed\) project | +| ↳ `style` | string | Project style \(e.g., classic, next-gen\) | +| ↳ `isPrivate` | boolean | Whether the project is private | +| ↳ `url` | string | REST API URL for this project | +| ↳ `leadDisplayName` | string | Display name of the project lead | +| ↳ `leadAccountId` | string | Account ID of the project lead | +| `total` | number | Total number of matching projects | +| `startAt` | number | Pagination start index | +| `maxResults` | number | Maximum results per page | +| `isLast` | boolean | Whether this is the last page of results | + +### `jira_get_project` + +Get the details of a single Jira project by its ID or key, including its type, lead, components, issue types, and versions. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `projectId` | string | Yes | The project ID or key \(e.g., "PROJ" or "10000"\) | +| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | ISO 8601 timestamp of the operation | +| `id` | string | Project ID | +| `key` | string | Project key \(e.g., PROJ\) | +| `name` | string | Project name | +| `description` | string | Project description | +| `projectTypeKey` | string | Project type key \(e.g., software, service_desk, business\) | +| `simplified` | boolean | Whether the project is a simplified \(team-managed\) project | +| `style` | string | Project style \(e.g., classic, next-gen\) | +| `isPrivate` | boolean | Whether the project is private | +| `url` | string | REST API URL for this project | +| `leadDisplayName` | string | Display name of the project lead | +| `leadAccountId` | string | Account ID of the project lead | +| `issueTypes` | array | Issue types available in this project | +| ↳ `id` | string | Issue type ID | +| ↳ `name` | string | Issue type name \(e.g., Task, Bug, Story\) | +| ↳ `subtask` | boolean | Whether this issue type is a subtask | + +### `jira_get_transitions` + +Get the workflow transitions available for an issue in its current status. Use the returned transition IDs with the Transition Issue operation. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `issueKey` | string | Yes | The issue key or ID \(e.g., PROJ-123\) | +| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | ISO 8601 timestamp of the operation | +| `issueKey` | string | Issue key the transitions belong to | +| `transitions` | array | Available workflow transitions for the issue | +| ↳ `id` | string | Transition ID \(use with Transition Issue\) | +| ↳ `name` | string | Transition name \(e.g., "Start Progress"\) | +| ↳ `toStatusId` | string | ID of the status the issue moves to | +| ↳ `toStatusName` | string | Name of the status the issue moves to | +| ↳ `toStatusCategory` | string | Status category key of the target status \(new, indeterminate, done\) | +| ↳ `isAvailable` | boolean | Whether the transition can currently be performed | +| ↳ `hasScreen` | boolean | Whether the transition requires a screen with fields | +| `total` | number | Number of available transitions | + +### `jira_list_issue_types` + +List all issue types visible to the user across projects (e.g., Task, Bug, Story, Epic, Subtask). Useful for discovering valid issue types before creating an issue. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | ISO 8601 timestamp of the operation | +| `issueTypes` | array | Array of issue types | +| ↳ `id` | string | Issue type ID | +| ↳ `name` | string | Issue type name \(e.g., Task, Bug, Story\) | +| ↳ `description` | string | Issue type description | +| ↳ `subtask` | boolean | Whether this issue type is a subtask | +| ↳ `hierarchyLevel` | number | Hierarchy level \(0 = standard, 1 = epic, -1 = subtask\) | +| ↳ `iconUrl` | string | URL of the issue type icon | +| ↳ `scope` | string | Project ID if this issue type is scoped to a team-managed project | +| `total` | number | Number of issue types returned | + +### `jira_get_fields` + +Get all system and custom fields defined in the Jira instance. Useful for discovering custom field IDs (e.g., customfield_10001) to use when writing or updating issues. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | ISO 8601 timestamp of the operation | +| `fields` | array | Array of Jira fields \(system and custom\) | +| ↳ `id` | string | Field ID \(e.g., summary, customfield_10001\) | +| ↳ `key` | string | Field key | +| ↳ `name` | string | Human-readable field name | +| ↳ `custom` | boolean | Whether this is a custom field | +| ↳ `navigable` | boolean | Whether the field is navigable in issue views | +| ↳ `searchable` | boolean | Whether the field can be used in JQL searches | +| ↳ `schemaType` | string | Field value type \(e.g., string, number, array, user\) | +| ↳ `customType` | string | Custom field type identifier \(only for custom fields\) | +| `total` | number | Number of fields returned | + ## Triggers diff --git a/apps/docs/content/docs/en/integrations/monday.mdx b/apps/docs/content/docs/en/integrations/monday.mdx index d60977dd329..35a2e25eced 100644 --- a/apps/docs/content/docs/en/integrations/monday.mdx +++ b/apps/docs/content/docs/en/integrations/monday.mdx @@ -242,6 +242,72 @@ Update column values of an item on a Monday.com board | ↳ `updatedAt` | string | Last updated timestamp | | ↳ `url` | string | Item URL | +### `monday_change_column_value` + +Update a single column's value on a Monday.com item + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardId` | string | Yes | The ID of the board containing the item | +| `itemId` | string | Yes | The ID of the item to update | +| `columnId` | string | Yes | The ID of the column to update \(e.g., "status", "date4"\) | +| `value` | string | Yes | The new column value as a JSON string \(e.g., \{"label":"Done"\} for a status column\) | +| `createLabelsIfMissing` | boolean | No | Create status/dropdown labels that do not yet exist on the column | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `item` | json | The updated item | +| ↳ `id` | string | Item ID | +| ↳ `name` | string | Item name | +| ↳ `state` | string | Item state | +| ↳ `boardId` | string | Board ID | +| ↳ `groupId` | string | Group ID | +| ↳ `groupTitle` | string | Group title | +| ↳ `columnValues` | array | Column values | +| ↳ `id` | string | Column ID | +| ↳ `text` | string | Text value | +| ↳ `value` | string | Raw JSON value | +| ↳ `type` | string | Column type | +| ↳ `createdAt` | string | Creation timestamp | +| ↳ `updatedAt` | string | Last updated timestamp | +| ↳ `url` | string | Item URL | + +### `monday_duplicate_item` + +Duplicate an existing item on a Monday.com board + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardId` | string | Yes | The ID of the board containing the item | +| `itemId` | string | Yes | The ID of the item to duplicate | +| `withUpdates` | boolean | No | Whether to also duplicate the item updates | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `item` | json | The duplicated item | +| ↳ `id` | string | Item ID | +| ↳ `name` | string | Item name | +| ↳ `state` | string | Item state | +| ↳ `boardId` | string | Board ID | +| ↳ `groupId` | string | Group ID | +| ↳ `groupTitle` | string | Group title | +| ↳ `columnValues` | array | Column values | +| ↳ `id` | string | Column ID | +| ↳ `text` | string | Text value | +| ↳ `value` | string | Raw JSON value | +| ↳ `type` | string | Column type | +| ↳ `createdAt` | string | Creation timestamp | +| ↳ `updatedAt` | string | Last updated timestamp | +| ↳ `url` | string | Item URL | + ### `monday_delete_item` Delete an item from a Monday.com board @@ -384,6 +450,80 @@ Create a new group on a Monday.com board | ↳ `deleted` | boolean | Whether deleted | | ↳ `position` | string | Group position | +### `monday_get_groups` + +Get the groups on a Monday.com board + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardId` | string | Yes | The ID of the board to retrieve groups from | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `groups` | array | Groups on the board | +| ↳ `id` | string | Group ID | +| ↳ `title` | string | Group title | +| ↳ `color` | string | Group color \(hex\) | +| ↳ `archived` | boolean | Whether the group is archived | +| ↳ `deleted` | boolean | Whether the group is deleted | +| ↳ `position` | string | Group position | +| `count` | number | Number of returned groups | + +### `monday_create_board` + +Create a new board in Monday.com + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardName` | string | Yes | The name of the new board | +| `boardKind` | string | Yes | The board kind: public, private, or share | +| `description` | string | No | The board description | +| `workspaceId` | string | No | The ID of the workspace to create the board in | +| `folderId` | string | No | The ID of the folder to create the board in | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `board` | json | The created board | +| ↳ `id` | string | Board ID | +| ↳ `name` | string | Board name | +| ↳ `description` | string | Board description | +| ↳ `state` | string | Board state | +| ↳ `boardKind` | string | Board kind \(public, private, share\) | +| ↳ `itemsCount` | number | Number of items | +| ↳ `url` | string | Board URL | +| ↳ `updatedAt` | string | Last updated timestamp | + +### `monday_create_column` + +Create a new column on a Monday.com board + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardId` | string | Yes | The ID of the board to create the column on | +| `columnTitle` | string | Yes | The title of the new column | +| `columnType` | string | Yes | The column type \(e.g., status, text, numbers, date, people, dropdown\) | +| `columnDescription` | string | No | The column description | +| `columnDefaults` | string | No | JSON string of default settings for the column \(e.g., status labels\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `column` | json | The created column | +| ↳ `id` | string | Column ID | +| ↳ `title` | string | Column title | +| ↳ `type` | string | Column type | + ## Triggers diff --git a/apps/docs/content/docs/en/integrations/slack.mdx b/apps/docs/content/docs/en/integrations/slack.mdx index a01cafe3b3b..c90a179c1b9 100644 --- a/apps/docs/content/docs/en/integrations/slack.mdx +++ b/apps/docs/content/docs/en/integrations/slack.mdx @@ -1686,6 +1686,186 @@ Publish a static view to a user's Home tab in Slack. Used to create or update th | ↳ `app_id` | string | Application identifier | | ↳ `bot_id` | string | Bot identifier | +### `slack_schedule_message` + +Schedule a message to be sent to a Slack channel or DM at a future time. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `channel` | string | Yes | Channel, private group, or DM to receive the message \(e.g., C1234567890\) | +| `postAt` | number | Yes | Unix timestamp \(seconds\) representing the future time the message should post | +| `text` | string | No | Message text to send \(supports Slack mrkdwn formatting\) | +| `blocks` | json | No | Block Kit layout blocks as a JSON array. When provided, text becomes the fallback notification text. | +| `threadTs` | string | No | Thread timestamp to reply to \(creates a scheduled thread reply\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `scheduledMessageId` | string | Identifier of the scheduled message \(used to delete it before it posts\) | +| `postAt` | number | Unix timestamp when the message will post | +| `channel` | string | Channel ID where the message is scheduled | +| `message` | object | The scheduled message object returned by Slack | + +### `slack_list_scheduled_messages` + +List pending scheduled messages in a Slack workspace, optionally filtered by channel. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `channel` | string | No | Optional channel ID to filter scheduled messages \(e.g., C1234567890\) | +| `limit` | number | No | Maximum number of scheduled messages to return | +| `cursor` | string | No | Pagination cursor \(next_cursor\) from a previous response | +| `oldest` | string | No | Unix timestamp of the oldest scheduled message to include | +| `latest` | string | No | Unix timestamp of the latest scheduled message to include | +| `teamId` | string | No | Encoded team ID \(required only with org-level tokens\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `scheduledMessages` | array | Array of pending scheduled message objects | +| ↳ `id` | string | Scheduled message ID | +| ↳ `channel_id` | string | Channel the message is scheduled for | +| ↳ `post_at` | number | Unix timestamp when the message will post | +| ↳ `date_created` | number | Unix timestamp when the schedule was created | +| ↳ `text` | string | Scheduled message text | +| `nextCursor` | string | Cursor for the next page \(null when there are no more pages\) | + +### `slack_delete_scheduled_message` + +Delete a pending scheduled message before it posts to Slack. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `channel` | string | Yes | Channel ID where the scheduled message is queued \(e.g., C1234567890\) | +| `scheduledMessageId` | string | Yes | Scheduled message ID from chat.scheduleMessage \(e.g., Q1234ABCD\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ok` | boolean | Whether the scheduled message was deleted successfully | + +### `slack_archive_conversation` + +Archive a Slack channel so it is closed to new activity. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `channel` | string | Yes | ID of the channel to archive \(e.g., C1234567890\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ok` | boolean | Whether the conversation was archived successfully | + +### `slack_rename_conversation` + +Rename an existing Slack channel. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `channel` | string | Yes | ID of the channel to rename \(e.g., C1234567890\) | +| `name` | string | Yes | New channel name \(lowercase letters, numbers, hyphens, underscores only; max 80 characters\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `channelInfo` | object | The channel object after renaming | +| ↳ `id` | string | Channel ID \(e.g., C1234567890\) | +| ↳ `name` | string | Channel name without # prefix | +| ↳ `is_channel` | boolean | Whether this is a channel | +| ↳ `is_private` | boolean | Whether channel is private | +| ↳ `is_archived` | boolean | Whether channel is archived | +| ↳ `is_general` | boolean | Whether this is the general channel | +| ↳ `is_member` | boolean | Whether the bot/user is a member | +| ↳ `is_shared` | boolean | Whether channel is shared across workspaces | +| ↳ `is_ext_shared` | boolean | Whether channel is externally shared | +| ↳ `is_org_shared` | boolean | Whether channel is org-wide shared | +| ↳ `num_members` | number | Number of members in the channel | +| ↳ `topic` | string | Channel topic | +| ↳ `purpose` | string | Channel purpose/description | +| ↳ `created` | number | Unix timestamp when channel was created | +| ↳ `creator` | string | User ID of channel creator | +| ↳ `updated` | number | Unix timestamp of last update | + +### `slack_set_conversation_topic` + +Set the topic for a Slack channel (max 250 characters). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `channel` | string | Yes | ID of the channel to update \(e.g., C1234567890\) | +| `topic` | string | Yes | New topic text \(max 250 characters; no formatting or linkification\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `channelInfo` | object | The channel object after updating the topic | +| ↳ `id` | string | Channel ID \(e.g., C1234567890\) | +| ↳ `name` | string | Channel name without # prefix | +| ↳ `is_channel` | boolean | Whether this is a channel | +| ↳ `is_private` | boolean | Whether channel is private | +| ↳ `is_archived` | boolean | Whether channel is archived | +| ↳ `is_general` | boolean | Whether this is the general channel | +| ↳ `is_member` | boolean | Whether the bot/user is a member | +| ↳ `is_shared` | boolean | Whether channel is shared across workspaces | +| ↳ `is_ext_shared` | boolean | Whether channel is externally shared | +| ↳ `is_org_shared` | boolean | Whether channel is org-wide shared | +| ↳ `num_members` | number | Number of members in the channel | +| ↳ `topic` | string | Channel topic | +| ↳ `purpose` | string | Channel purpose/description | +| ↳ `created` | number | Unix timestamp when channel was created | +| ↳ `creator` | string | User ID of channel creator | +| ↳ `updated` | number | Unix timestamp of last update | + +### `slack_set_conversation_purpose` + +Set the purpose (description) for a Slack channel (max 250 characters). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `channel` | string | Yes | ID of the channel to update \(e.g., C1234567890\) | +| `purpose` | string | Yes | New purpose/description text \(max 250 characters\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `purpose` | string | The purpose/description that was set on the channel | + ## Triggers @@ -1734,6 +1914,9 @@ Trigger workflow from Slack events like mentions, messages, and reactions | ↳ `callback_id` | string | Callback ID of the shortcut or view. Present for shortcuts and modal submissions | | ↳ `api_app_id` | string | Slack app ID. Present for interactivity and slash commands | | ↳ `message_ts` | string | Timestamp of the message the interaction originated from. Present for block_actions | +| ↳ `view` | json | Full Slack view object for modal interactions: state.values \(submitted input values\), private_metadata, id, callback_id, and hash. Present for view_submission/view_closed; null otherwise | +| ↳ `message` | json | Full source message object the interaction came from, including its blocks and text. Present for block_actions on a message; null otherwise | +| ↳ `state` | json | Current values of all stateful elements in the surface \(state.values\) at the time of a block action — e.g. inputs read on a button click. Present for block_actions; null otherwise | | ↳ `hasFiles` | boolean | Whether the message has file attachments | | ↳ `files` | file[] | File attachments downloaded from the message \(if includeFiles is enabled and bot token is provided\) | diff --git a/apps/docs/content/docs/en/integrations/trello.mdx b/apps/docs/content/docs/en/integrations/trello.mdx index 6354aa115ad..86cdef85b99 100644 --- a/apps/docs/content/docs/en/integrations/trello.mdx +++ b/apps/docs/content/docs/en/integrations/trello.mdx @@ -251,4 +251,161 @@ Add a comment to a Trello card | ↳ `id` | string | List ID | | ↳ `name` | string | List name | +### `trello_create_board` + +Create a new Trello board + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `name` | string | Yes | Name of the board | +| `desc` | string | No | Description of the board | +| `idOrganization` | string | No | ID or name of the workspace/organization the board belongs to | +| `defaultLists` | boolean | No | Whether to create the default lists \(To Do, Doing, Done\) on the new board | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `board` | json | Created board \(id, name, desc, url, closed, idOrganization\) | +| ↳ `id` | string | Board ID | +| ↳ `name` | string | Board name | +| ↳ `desc` | string | Board description | +| ↳ `url` | string | Full board URL | +| ↳ `closed` | boolean | Whether the board is closed | +| ↳ `idOrganization` | string | ID of the workspace/organization the board belongs to | + +### `trello_get_board` + +Retrieve a single Trello board by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardId` | string | Yes | Trello board ID \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `board` | json | Board \(id, name, desc, url, closed, idOrganization\) | +| ↳ `id` | string | Board ID | +| ↳ `name` | string | Board name | +| ↳ `desc` | string | Board description | +| ↳ `url` | string | Full board URL | +| ↳ `closed` | boolean | Whether the board is closed | +| ↳ `idOrganization` | string | ID of the workspace/organization the board belongs to | + +### `trello_create_list` + +Create a new list on a Trello board + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardId` | string | Yes | Trello board ID the list belongs to \(24-character hex string\) | +| `name` | string | Yes | Name of the list | +| `pos` | string | No | Position of the list \(top, bottom, or positive float\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `list` | json | Created list \(id, name, closed, pos, idBoard\) | +| ↳ `id` | string | List ID | +| ↳ `name` | string | List name | +| ↳ `closed` | boolean | Whether the list is archived | +| ↳ `pos` | number | List position on the board | +| ↳ `idBoard` | string | Board ID containing the list | + +### `trello_get_card` + +Retrieve a single Trello card by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `card` | json | Card \(id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete\) | +| ↳ `id` | string | Card ID | +| ↳ `name` | string | Card name | +| ↳ `desc` | string | Card description | +| ↳ `url` | string | Full card URL | +| ↳ `idBoard` | string | Board ID containing the card | +| ↳ `idList` | string | List ID containing the card | +| ↳ `closed` | boolean | Whether the card is archived | +| ↳ `labelIds` | array | Label IDs applied to the card | +| ↳ `labels` | array | Labels applied to the card | +| ↳ `id` | string | Label ID | +| ↳ `name` | string | Label name | +| ↳ `color` | string | Label color | +| ↳ `due` | string | Card due date in ISO 8601 format | +| ↳ `dueComplete` | boolean | Whether the due date is complete | + +### `trello_add_checklist` + +Add a checklist to a Trello card + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID to add the checklist to \(24-character hex string\) | +| `name` | string | Yes | Name of the checklist | +| `pos` | string | No | Position of the checklist \(top, bottom, or positive float\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `checklist` | json | Created checklist \(id, name, idCard, idBoard, pos\) | +| ↳ `id` | string | Checklist ID | +| ↳ `name` | string | Checklist name | +| ↳ `idCard` | string | Card ID containing the checklist | +| ↳ `idBoard` | string | Board ID containing the checklist | +| ↳ `pos` | number | Checklist position on the card | + +### `trello_add_label` + +Attach an existing label to a Trello card + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID to attach the label to \(24-character hex string\) | +| `labelId` | string | Yes | ID of the label to attach \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `labelIds` | array | Label IDs now applied to the card | + +### `trello_add_member` + +Assign a member to a Trello card + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID to assign the member to \(24-character hex string\) | +| `memberId` | string | Yes | ID of the member to assign \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `memberIds` | array | Member IDs now assigned to the card | + diff --git a/apps/sim/app/api/tools/asana/add-followers/route.ts b/apps/sim/app/api/tools/asana/add-followers/route.ts new file mode 100644 index 00000000000..d9ada412efd --- /dev/null +++ b/apps/sim/app/api/tools/asana/add-followers/route.ts @@ -0,0 +1,103 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { asanaAddFollowersContract } from '@/lib/api/contracts/tools/asana' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AsanaAddFollowersAPI') + +interface AsanaFollower { + gid: string + name: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(asanaAddFollowersContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, taskGid, followers } = parsed.data.body + + const taskGidValidation = validateAlphanumericId(taskGid, 'taskGid', 100) + if (!taskGidValidation.isValid) { + return NextResponse.json({ error: taskGidValidation.error }, { status: 400 }) + } + + for (const follower of followers) { + const followerValidation = validateAlphanumericId(follower, 'follower', 100) + if (!followerValidation.isValid) { + return NextResponse.json({ error: followerValidation.error }, { status: 400 }) + } + } + + const url = `https://app.asana.com/api/1.0/tasks/${taskGid}/addFollowers?opt_fields=name,followers.name` + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ data: { followers } }), + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `Asana API error: ${response.status} ${response.statusText}` + + try { + const errorData = JSON.parse(errorText) + const asanaError = errorData.errors?.[0] + if (asanaError) { + errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})` + } + logger.error('Asana API error:', { + status: response.status, + statusText: response.statusText, + error: errorData, + }) + } catch (_e) { + logger.error('Asana API error (unparsed):', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + } + + return NextResponse.json( + { success: false, error: errorMessage, details: errorText }, + { status: response.status } + ) + } + + const result = await response.json() + const task = result.data + const taskFollowers: AsanaFollower[] = Array.isArray(task.followers) ? task.followers : [] + + return NextResponse.json({ + success: true, + ts: new Date().toISOString(), + gid: task.gid, + name: task.name || '', + followers: taskFollowers.map((follower) => ({ + gid: follower.gid, + name: follower.name, + })), + }) + } catch (error) { + logger.error('Error adding followers to Asana task:', error) + return NextResponse.json( + { error: 'Failed to add followers to Asana task', details: (error as Error).message }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/asana/create-project/route.ts b/apps/sim/app/api/tools/asana/create-project/route.ts new file mode 100644 index 00000000000..1af9133f375 --- /dev/null +++ b/apps/sim/app/api/tools/asana/create-project/route.ts @@ -0,0 +1,103 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { asanaCreateProjectContract } from '@/lib/api/contracts/tools/asana' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AsanaCreateProjectAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(asanaCreateProjectContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, workspace, name, notes } = parsed.data.body + + const workspaceValidation = validateAlphanumericId(workspace, 'workspace', 100) + if (!workspaceValidation.isValid) { + return NextResponse.json({ error: workspaceValidation.error }, { status: 400 }) + } + + const projectData: Record = { name, workspace } + if (notes) { + projectData.notes = notes + } + + const response = await fetch( + 'https://app.asana.com/api/1.0/projects?opt_fields=name,notes,archived,color,created_at,modified_at,permalink_url', + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ data: projectData }), + } + ) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `Asana API error: ${response.status} ${response.statusText}` + + try { + const errorData = JSON.parse(errorText) + const asanaError = errorData.errors?.[0] + if (asanaError) { + errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})` + } + logger.error('Asana API error:', { + status: response.status, + statusText: response.statusText, + error: errorData, + }) + } catch (_e) { + logger.error('Asana API error (unparsed):', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + } + + return NextResponse.json( + { success: false, error: errorMessage, details: errorText }, + { status: response.status } + ) + } + + const result = await response.json() + const project = result.data + + return NextResponse.json({ + success: true, + ts: new Date().toISOString(), + gid: project.gid, + name: project.name, + notes: project.notes || '', + archived: project.archived ?? false, + color: project.color ?? null, + created_at: project.created_at, + modified_at: project.modified_at, + permalink_url: project.permalink_url, + }) + } catch (error) { + logger.error('Error creating Asana project:', { + error: toError(error).message, + stack: error instanceof Error ? error.stack : undefined, + }) + + return NextResponse.json( + { error: getErrorMessage(error, 'Internal server error'), success: false }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/asana/create-section/route.ts b/apps/sim/app/api/tools/asana/create-section/route.ts new file mode 100644 index 00000000000..0d351d6da67 --- /dev/null +++ b/apps/sim/app/api/tools/asana/create-section/route.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { asanaCreateSectionContract } from '@/lib/api/contracts/tools/asana' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AsanaCreateSectionAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(asanaCreateSectionContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, projectGid, name } = parsed.data.body + + const projectGidValidation = validateAlphanumericId(projectGid, 'projectGid', 100) + if (!projectGidValidation.isValid) { + return NextResponse.json({ error: projectGidValidation.error }, { status: 400 }) + } + + const url = `https://app.asana.com/api/1.0/projects/${projectGid}/sections` + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ data: { name } }), + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `Asana API error: ${response.status} ${response.statusText}` + + try { + const errorData = JSON.parse(errorText) + const asanaError = errorData.errors?.[0] + if (asanaError) { + errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})` + } + logger.error('Asana API error:', { + status: response.status, + statusText: response.statusText, + error: errorData, + }) + } catch (_e) { + logger.error('Asana API error (unparsed):', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + } + + return NextResponse.json( + { success: false, error: errorMessage, details: errorText }, + { status: response.status } + ) + } + + const result = await response.json() + const section = result.data + + return NextResponse.json({ + success: true, + ts: new Date().toISOString(), + gid: section.gid, + name: section.name, + created_at: section.created_at, + }) + } catch (error) { + logger.error('Error creating Asana section:', { + error: toError(error).message, + stack: error instanceof Error ? error.stack : undefined, + }) + + return NextResponse.json( + { error: getErrorMessage(error, 'Internal server error'), success: false }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/asana/create-subtask/route.ts b/apps/sim/app/api/tools/asana/create-subtask/route.ts new file mode 100644 index 00000000000..a1ce4676a07 --- /dev/null +++ b/apps/sim/app/api/tools/asana/create-subtask/route.ts @@ -0,0 +1,106 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { asanaCreateSubtaskContract } from '@/lib/api/contracts/tools/asana' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AsanaCreateSubtaskAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(asanaCreateSubtaskContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, taskGid, name, notes, assignee, due_on } = parsed.data.body + + const taskGidValidation = validateAlphanumericId(taskGid, 'taskGid', 100) + if (!taskGidValidation.isValid) { + return NextResponse.json({ error: taskGidValidation.error }, { status: 400 }) + } + + const subtaskData: Record = { name } + if (notes) { + subtaskData.notes = notes + } + if (assignee) { + subtaskData.assignee = assignee + } + if (due_on) { + subtaskData.due_on = due_on + } + + const url = `https://app.asana.com/api/1.0/tasks/${taskGid}/subtasks?opt_fields=name,notes,completed,created_at,permalink_url` + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ data: subtaskData }), + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `Asana API error: ${response.status} ${response.statusText}` + + try { + const errorData = JSON.parse(errorText) + const asanaError = errorData.errors?.[0] + if (asanaError) { + errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})` + } + logger.error('Asana API error:', { + status: response.status, + statusText: response.statusText, + error: errorData, + }) + } catch (_e) { + logger.error('Asana API error (unparsed):', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + } + + return NextResponse.json( + { success: false, error: errorMessage, details: errorText }, + { status: response.status } + ) + } + + const result = await response.json() + const task = result.data + + return NextResponse.json({ + success: true, + ts: new Date().toISOString(), + gid: task.gid, + name: task.name, + notes: task.notes || '', + completed: task.completed || false, + created_at: task.created_at, + permalink_url: task.permalink_url, + }) + } catch (error) { + logger.error('Error creating Asana subtask:', { + error: toError(error).message, + stack: error instanceof Error ? error.stack : undefined, + }) + + return NextResponse.json( + { error: getErrorMessage(error, 'Internal server error'), success: false }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/asana/create-task/route.ts b/apps/sim/app/api/tools/asana/create-task/route.ts index df188ea652c..39521ca9d04 100644 --- a/apps/sim/app/api/tools/asana/create-task/route.ts +++ b/apps/sim/app/api/tools/asana/create-task/route.ts @@ -27,7 +27,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: workspaceValidation.error }, { status: 400 }) } - const url = 'https://app.asana.com/api/1.0/tasks' + const url = + 'https://app.asana.com/api/1.0/tasks?opt_fields=name,notes,completed,created_at,permalink_url' const taskData: Record = { name, diff --git a/apps/sim/app/api/tools/asana/delete-task/route.ts b/apps/sim/app/api/tools/asana/delete-task/route.ts new file mode 100644 index 00000000000..6ca717e4f62 --- /dev/null +++ b/apps/sim/app/api/tools/asana/delete-task/route.ts @@ -0,0 +1,81 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { asanaDeleteTaskContract } from '@/lib/api/contracts/tools/asana' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AsanaDeleteTaskAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(asanaDeleteTaskContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, taskGid } = parsed.data.body + + const taskGidValidation = validateAlphanumericId(taskGid, 'taskGid', 100) + if (!taskGidValidation.isValid) { + return NextResponse.json({ error: taskGidValidation.error }, { status: 400 }) + } + + const url = `https://app.asana.com/api/1.0/tasks/${taskGid}` + + const response = await fetch(url, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `Asana API error: ${response.status} ${response.statusText}` + + try { + const errorData = JSON.parse(errorText) + const asanaError = errorData.errors?.[0] + if (asanaError) { + errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})` + } + logger.error('Asana API error:', { + status: response.status, + statusText: response.statusText, + error: errorData, + }) + } catch (_e) { + logger.error('Asana API error (unparsed):', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + } + + return NextResponse.json( + { success: false, error: errorMessage, details: errorText }, + { status: response.status } + ) + } + + return NextResponse.json({ + success: true, + ts: new Date().toISOString(), + gid: taskGid, + deleted: true, + }) + } catch (error) { + logger.error('Error deleting Asana task:', error) + return NextResponse.json( + { error: 'Failed to delete Asana task', details: (error as Error).message }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/asana/get-project/route.ts b/apps/sim/app/api/tools/asana/get-project/route.ts new file mode 100644 index 00000000000..3e7022a5a9b --- /dev/null +++ b/apps/sim/app/api/tools/asana/get-project/route.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { asanaGetProjectContract } from '@/lib/api/contracts/tools/asana' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AsanaGetProjectAPI') + +const PROJECT_OPT_FIELDS = 'name,notes,archived,color,created_at,modified_at,permalink_url' + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(asanaGetProjectContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, projectGid } = parsed.data.body + + const projectGidValidation = validateAlphanumericId(projectGid, 'projectGid', 100) + if (!projectGidValidation.isValid) { + return NextResponse.json({ error: projectGidValidation.error }, { status: 400 }) + } + + const url = `https://app.asana.com/api/1.0/projects/${projectGid}?opt_fields=${PROJECT_OPT_FIELDS}` + + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `Asana API error: ${response.status} ${response.statusText}` + + try { + const errorData = JSON.parse(errorText) + const asanaError = errorData.errors?.[0] + if (asanaError) { + errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})` + } + logger.error('Asana API error:', { + status: response.status, + statusText: response.statusText, + error: errorData, + }) + } catch (_e) { + logger.error('Asana API error (unparsed):', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + } + + return NextResponse.json( + { success: false, error: errorMessage, details: errorText }, + { status: response.status } + ) + } + + const result = await response.json() + const project = result.data + + return NextResponse.json({ + success: true, + ts: new Date().toISOString(), + gid: project.gid, + name: project.name, + notes: project.notes || '', + archived: project.archived ?? false, + color: project.color ?? null, + created_at: project.created_at, + modified_at: project.modified_at, + permalink_url: project.permalink_url, + }) + } catch (error) { + logger.error('Error processing request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Asana project', details: (error as Error).message }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/asana/list-sections/route.ts b/apps/sim/app/api/tools/asana/list-sections/route.ts new file mode 100644 index 00000000000..05524251068 --- /dev/null +++ b/apps/sim/app/api/tools/asana/list-sections/route.ts @@ -0,0 +1,93 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { asanaListSectionsContract } from '@/lib/api/contracts/tools/asana' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AsanaListSectionsAPI') + +interface AsanaSection { + gid: string + name: string + resource_type?: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(asanaListSectionsContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, projectGid } = parsed.data.body + + const projectGidValidation = validateAlphanumericId(projectGid, 'projectGid', 100) + if (!projectGidValidation.isValid) { + return NextResponse.json({ error: projectGidValidation.error }, { status: 400 }) + } + + const url = `https://app.asana.com/api/1.0/projects/${projectGid}/sections` + + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `Asana API error: ${response.status} ${response.statusText}` + + try { + const errorData = JSON.parse(errorText) + const asanaError = errorData.errors?.[0] + if (asanaError) { + errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})` + } + logger.error('Asana API error:', { + status: response.status, + statusText: response.statusText, + error: errorData, + }) + } catch (_e) { + logger.error('Asana API error (unparsed):', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + } + + return NextResponse.json( + { success: false, error: errorMessage, details: errorText }, + { status: response.status } + ) + } + + const result = await response.json() + const sections: AsanaSection[] = Array.isArray(result.data) ? result.data : [] + + return NextResponse.json({ + success: true, + ts: new Date().toISOString(), + sections: sections.map((section) => ({ + gid: section.gid, + name: section.name, + resource_type: section.resource_type, + })), + }) + } catch (error) { + logger.error('Error processing request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Asana sections', details: (error as Error).message }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/asana/list-workspaces/route.ts b/apps/sim/app/api/tools/asana/list-workspaces/route.ts new file mode 100644 index 00000000000..1c550e2c4d7 --- /dev/null +++ b/apps/sim/app/api/tools/asana/list-workspaces/route.ts @@ -0,0 +1,87 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { asanaListWorkspacesContract } from '@/lib/api/contracts/tools/asana' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AsanaListWorkspacesAPI') + +interface AsanaWorkspace { + gid: string + name: string + resource_type?: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(asanaListWorkspacesContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken } = parsed.data.body + + const url = 'https://app.asana.com/api/1.0/workspaces?limit=100' + + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `Asana API error: ${response.status} ${response.statusText}` + + try { + const errorData = JSON.parse(errorText) + const asanaError = errorData.errors?.[0] + if (asanaError) { + errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})` + } + logger.error('Asana API error:', { + status: response.status, + statusText: response.statusText, + error: errorData, + }) + } catch (_e) { + logger.error('Asana API error (unparsed):', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + } + + return NextResponse.json( + { success: false, error: errorMessage, details: errorText }, + { status: response.status } + ) + } + + const result = await response.json() + const workspaces: AsanaWorkspace[] = Array.isArray(result.data) ? result.data : [] + + return NextResponse.json({ + success: true, + ts: new Date().toISOString(), + workspaces: workspaces.map((workspace) => ({ + gid: workspace.gid, + name: workspace.name, + resource_type: workspace.resource_type, + })), + }) + } catch (error) { + logger.error('Error processing request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Asana workspaces', details: (error as Error).message }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/blocks/blocks/asana.ts b/apps/sim/blocks/blocks/asana.ts index 1695a702041..e7a3ef9ed34 100644 --- a/apps/sim/blocks/blocks/asana.ts +++ b/apps/sim/blocks/blocks/asana.ts @@ -27,6 +27,14 @@ export const AsanaBlock: BlockConfig = { { label: 'Get Projects', id: 'get_projects' }, { label: 'Search Tasks', id: 'search_tasks' }, { label: 'Add Comment', id: 'add_comment' }, + { label: 'Create Subtask', id: 'create_subtask' }, + { label: 'Delete Task', id: 'delete_task' }, + { label: 'Add Followers', id: 'add_followers' }, + { label: 'Create Project', id: 'create_project' }, + { label: 'Get Project', id: 'get_project' }, + { label: 'List Workspaces', id: 'list_workspaces' }, + { label: 'Create Section', id: 'create_section' }, + { label: 'List Sections', id: 'list_sections' }, ], value: () => 'get_task', }, @@ -234,6 +242,143 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n value: ['add_comment'], }, }, + { + id: 'createProjectWorkspaceSelector', + title: 'Workspace', + type: 'project-selector', + canonicalParamId: 'createProject_workspace', + serviceId: 'asana', + selectorKey: 'asana.workspaces', + placeholder: 'Select Asana workspace', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['create_project'], + }, + required: true, + }, + { + id: 'createProject_workspace', + title: 'Workspace GID', + type: 'short-input', + canonicalParamId: 'createProject_workspace', + placeholder: 'Enter Asana workspace GID', + dependsOn: ['credential'], + mode: 'advanced', + condition: { + field: 'operation', + value: ['create_project'], + }, + required: true, + }, + { + id: 'projectGid', + title: 'Project GID', + type: 'short-input', + required: true, + placeholder: 'Enter Asana project GID', + condition: { + field: 'operation', + value: ['get_project', 'create_section', 'list_sections'], + }, + }, + { + id: 'subtaskParentGid', + title: 'Parent Task GID', + type: 'short-input', + required: true, + placeholder: 'Enter parent task GID', + condition: { + field: 'operation', + value: ['create_subtask'], + }, + }, + { + id: 'taskGid', + title: 'Task GID', + type: 'short-input', + required: true, + placeholder: 'Enter Asana task GID', + condition: { + field: 'operation', + value: ['delete_task', 'add_followers'], + }, + }, + { + id: 'name', + title: 'Name', + type: 'short-input', + required: true, + placeholder: 'Enter a name', + condition: { + field: 'operation', + value: ['create_subtask', 'create_project', 'create_section'], + }, + }, + { + id: 'notes', + title: 'Notes', + type: 'long-input', + placeholder: 'Enter notes or description', + condition: { + field: 'operation', + value: ['create_subtask', 'create_project'], + }, + }, + { + id: 'assignee', + title: 'Assignee GID', + type: 'short-input', + placeholder: 'Enter assignee user GID', + condition: { + field: 'operation', + value: ['create_subtask'], + }, + }, + { + id: 'due_on', + title: 'Due Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + condition: { + field: 'operation', + value: ['create_subtask'], + }, + }, + { + id: 'followers', + title: 'Followers', + type: 'short-input', + required: true, + placeholder: 'Comma-separated user GIDs (e.g. 12345, 67890)', + condition: { + field: 'operation', + value: ['add_followers'], + }, + }, + { + id: 'projects', + title: 'Projects', + type: 'short-input', + placeholder: 'Comma-separated project GIDs to filter by', + mode: 'advanced', + condition: { + field: 'operation', + value: ['search_tasks'], + }, + }, + { + id: 'completed', + title: 'Completion', + type: 'checkbox-list', + options: [{ label: 'Completed', id: 'completed' }], + mode: 'advanced', + condition: { + field: 'operation', + value: ['update_task', 'search_tasks'], + }, + }, ], tools: { access: [ @@ -243,6 +388,14 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n 'asana_get_projects', 'asana_search_tasks', 'asana_add_comment', + 'asana_create_subtask', + 'asana_delete_task', + 'asana_add_followers', + 'asana_create_project', + 'asana_get_project', + 'asana_list_workspaces', + 'asana_create_section', + 'asana_list_sections', ], config: { tool: (params) => { @@ -259,6 +412,22 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n return 'asana_search_tasks' case 'add_comment': return 'asana_add_comment' + case 'create_subtask': + return 'asana_create_subtask' + case 'delete_task': + return 'asana_delete_task' + case 'add_followers': + return 'asana_add_followers' + case 'create_project': + return 'asana_create_project' + case 'get_project': + return 'asana_get_project' + case 'list_workspaces': + return 'asana_list_workspaces' + case 'create_section': + return 'asana_create_section' + case 'list_sections': + return 'asana_list_sections' default: return 'asana_get_task' } @@ -273,6 +442,15 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n .filter((p: string) => p.length > 0) : undefined + // Only send a completion value when the user actually checked the box; an + // empty/untouched checkbox must omit the field (not send `false`), so + // update_task doesn't silently un-complete a task and search_tasks doesn't + // implicitly filter to incomplete tasks. + const completedValue = + Array.isArray(params.completed) && params.completed.length > 0 + ? params.completed.includes('completed') + : undefined + const baseParams = { accessToken: oauthCredential?.accessToken, } @@ -302,7 +480,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n name: params.name, notes: params.notes, assignee: params.assignee, - completed: params.completed?.includes('completed'), + completed: completedValue, due_on: params.due_on, } case 'get_projects': @@ -317,7 +495,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n text: params.searchText, assignee: params.assignee, projects: projectsArray, - completed: params.completed?.includes('completed'), + completed: completedValue, } case 'add_comment': return { @@ -325,6 +503,58 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n taskGid: params.taskGid, text: params.commentText, } + case 'create_subtask': + return { + ...baseParams, + taskGid: params.subtaskParentGid, + name: params.name, + notes: params.notes, + assignee: params.assignee, + due_on: params.due_on, + } + case 'delete_task': + return { + ...baseParams, + taskGid: params.taskGid, + } + case 'add_followers': + return { + ...baseParams, + taskGid: params.taskGid, + followers: params.followers + ? params.followers + .split(',') + .map((f: string) => f.trim()) + .filter((f: string) => f.length > 0) + : [], + } + case 'create_project': + return { + ...baseParams, + workspace: params.createProject_workspace, + name: params.name, + notes: params.notes, + } + case 'get_project': + return { + ...baseParams, + projectGid: params.projectGid, + } + case 'list_workspaces': + return { + ...baseParams, + } + case 'create_section': + return { + ...baseParams, + projectGid: params.projectGid, + name: params.name, + } + case 'list_sections': + return { + ...baseParams, + projectGid: params.projectGid, + } default: return baseParams } @@ -347,6 +577,13 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n completed: { type: 'array', description: 'Completion status' }, searchText: { type: 'string', description: 'Search text' }, commentText: { type: 'string', description: 'Comment text' }, + createProject_workspace: { + type: 'string', + description: 'Workspace GID for creating a project', + }, + projectGid: { type: 'string', description: 'Project GID' }, + subtaskParentGid: { type: 'string', description: 'Parent task GID for creating a subtask' }, + followers: { type: 'string', description: 'Comma-separated user GIDs to add as followers' }, }, outputs: { success: { type: 'boolean', description: 'Operation success status' }, @@ -364,6 +601,12 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n permalink_url: { type: 'string', description: 'URL to the resource in Asana' }, tasks: { type: 'json', description: 'Array of tasks' }, projects: { type: 'json', description: 'Array of projects' }, + workspaces: { type: 'json', description: 'Array of workspaces' }, + sections: { type: 'json', description: 'Array of sections' }, + followers: { type: 'json', description: 'Array of followers on the task' }, + archived: { type: 'boolean', description: 'Whether the project is archived' }, + color: { type: 'string', description: 'Project color' }, + deleted: { type: 'boolean', description: 'Whether the task was deleted' }, }, } diff --git a/apps/sim/blocks/blocks/context_dev.ts b/apps/sim/blocks/blocks/context_dev.ts index 26298c41ef8..c96a0690d42 100644 --- a/apps/sim/blocks/blocks/context_dev.ts +++ b/apps/sim/blocks/blocks/context_dev.ts @@ -317,6 +317,22 @@ Do not include any explanations, markdown formatting, or other text outside the mode: 'advanced', condition: { field: 'operation', value: 'search' }, }, + { + id: 'numResults', + title: 'Number of Results', + type: 'short-input', + placeholder: '10 to 100 (default 10)', + mode: 'advanced', + condition: { field: 'operation', value: 'search' }, + }, + { + id: 'country', + title: 'Country', + type: 'short-input', + placeholder: 'ISO 3166-1 alpha-2 code (e.g., US)', + mode: 'advanced', + condition: { field: 'operation', value: 'search' }, + }, { id: 'factCheck', title: 'Fact Check', @@ -634,6 +650,8 @@ Do not include any explanations, markdown formatting, or other text outside the const exclude = toStringArray(params.excludeDomains) if (exclude?.length) result.excludeDomains = exclude setString('freshness') + setNumber('numResults') + setString('country') setBool('queryFanout') setBool('markdownEnabled') setNumber('timeoutMS') diff --git a/apps/sim/blocks/blocks/google_docs.ts b/apps/sim/blocks/blocks/google_docs.ts index 36834c53985..578ec180b0a 100644 --- a/apps/sim/blocks/blocks/google_docs.ts +++ b/apps/sim/blocks/blocks/google_docs.ts @@ -33,6 +33,12 @@ export const GoogleDocsBlock: BlockConfig = { { label: 'Insert Image', id: 'insert_image' }, { label: 'Insert Page Break', id: 'insert_page_break' }, { label: 'Apply Text Style', id: 'update_text_style' }, + { label: 'Apply Paragraph Style', id: 'update_paragraph_style' }, + { label: 'Create Bullets', id: 'create_paragraph_bullets' }, + { label: 'Delete Bullets', id: 'delete_paragraph_bullets' }, + { label: 'Delete Content Range', id: 'delete_content_range' }, + { label: 'Create Named Range', id: 'create_named_range' }, + { label: 'Delete Named Range', id: 'delete_named_range' }, ], value: () => 'read', }, @@ -82,6 +88,12 @@ export const GoogleDocsBlock: BlockConfig = { 'insert_image', 'insert_page_break', 'update_text_style', + 'update_paragraph_style', + 'create_paragraph_bullets', + 'delete_paragraph_bullets', + 'delete_content_range', + 'create_named_range', + 'delete_named_range', ], }, }, @@ -105,6 +117,12 @@ export const GoogleDocsBlock: BlockConfig = { 'insert_image', 'insert_page_break', 'update_text_style', + 'update_paragraph_style', + 'create_paragraph_bullets', + 'delete_paragraph_bullets', + 'delete_content_range', + 'create_named_range', + 'delete_named_range', ], }, }, @@ -275,13 +293,23 @@ Return ONLY the text to insert - no explanations, no extra text.`, condition: { field: 'operation', value: 'insert_image' }, mode: 'advanced', }, - // Apply Text Style fields + // Range fields shared by style/bullet/range/named-range operations { id: 'startIndex', title: 'Start Index', type: 'short-input', placeholder: 'Start character index (inclusive)', - condition: { field: 'operation', value: 'update_text_style' }, + condition: { + field: 'operation', + value: [ + 'update_text_style', + 'update_paragraph_style', + 'create_paragraph_bullets', + 'delete_paragraph_bullets', + 'delete_content_range', + 'create_named_range', + ], + }, required: true, }, { @@ -289,7 +317,17 @@ Return ONLY the text to insert - no explanations, no extra text.`, title: 'End Index', type: 'short-input', placeholder: 'End character index (exclusive)', - condition: { field: 'operation', value: 'update_text_style' }, + condition: { + field: 'operation', + value: [ + 'update_text_style', + 'update_paragraph_style', + 'create_paragraph_bullets', + 'delete_paragraph_bullets', + 'delete_content_range', + 'create_named_range', + ], + }, required: true, }, { @@ -318,6 +356,77 @@ Return ONLY the text to insert - no explanations, no extra text.`, condition: { field: 'operation', value: 'update_text_style' }, mode: 'advanced', }, + // Apply Paragraph Style fields + { + id: 'namedStyleType', + title: 'Paragraph Style', + type: 'dropdown', + options: [ + { label: 'Default (unchanged)', id: '' }, + { label: 'Normal Text', id: 'NORMAL_TEXT' }, + { label: 'Title', id: 'TITLE' }, + { label: 'Subtitle', id: 'SUBTITLE' }, + { label: 'Heading 1', id: 'HEADING_1' }, + { label: 'Heading 2', id: 'HEADING_2' }, + { label: 'Heading 3', id: 'HEADING_3' }, + { label: 'Heading 4', id: 'HEADING_4' }, + { label: 'Heading 5', id: 'HEADING_5' }, + { label: 'Heading 6', id: 'HEADING_6' }, + ], + condition: { field: 'operation', value: 'update_paragraph_style' }, + }, + { + id: 'alignment', + title: 'Alignment', + type: 'dropdown', + options: [ + { label: 'Default (unchanged)', id: '' }, + { label: 'Left', id: 'LEFT' }, + { label: 'Center', id: 'CENTER' }, + { label: 'Right', id: 'RIGHT' }, + { label: 'Justify', id: 'JUSTIFY' }, + ], + condition: { field: 'operation', value: 'update_paragraph_style' }, + }, + // Create Bullets fields + { + id: 'bulletPreset', + title: 'Bullet Style', + type: 'dropdown', + options: [ + { label: 'Disc / Circle / Square', id: 'BULLET_DISC_CIRCLE_SQUARE' }, + { label: 'Checkbox', id: 'BULLET_CHECKBOX' }, + { label: 'Arrow / Diamond / Disc', id: 'BULLET_ARROW_DIAMOND_DISC' }, + { label: 'Star / Circle / Square', id: 'BULLET_STAR_CIRCLE_SQUARE' }, + { label: 'Numbered: Decimal / Alpha / Roman', id: 'NUMBERED_DECIMAL_ALPHA_ROMAN' }, + { label: 'Numbered: Decimal Nested', id: 'NUMBERED_DECIMAL_NESTED' }, + ], + condition: { field: 'operation', value: 'create_paragraph_bullets' }, + }, + // Create Named Range fields + { + id: 'name', + title: 'Range Name', + type: 'short-input', + placeholder: 'Name for the range (1-256 characters)', + condition: { field: 'operation', value: 'create_named_range' }, + required: true, + }, + // Delete Named Range fields + { + id: 'namedRangeId', + title: 'Named Range ID', + type: 'short-input', + placeholder: 'ID of the named range to delete', + condition: { field: 'operation', value: 'delete_named_range' }, + }, + { + id: 'namedRangeName', + title: 'Named Range Name', + type: 'short-input', + placeholder: 'Name of the named range(s) to delete', + condition: { field: 'operation', value: 'delete_named_range' }, + }, // Shared insertion index (advanced) for the insert operations { id: 'index', @@ -342,6 +451,12 @@ Return ONLY the text to insert - no explanations, no extra text.`, 'google_docs_insert_image', 'google_docs_insert_page_break', 'google_docs_update_text_style', + 'google_docs_update_paragraph_style', + 'google_docs_create_paragraph_bullets', + 'google_docs_delete_paragraph_bullets', + 'google_docs_delete_content_range', + 'google_docs_create_named_range', + 'google_docs_delete_named_range', ], config: { tool: (params) => { @@ -364,6 +479,18 @@ Return ONLY the text to insert - no explanations, no extra text.`, return 'google_docs_insert_page_break' case 'update_text_style': return 'google_docs_update_text_style' + case 'update_paragraph_style': + return 'google_docs_update_paragraph_style' + case 'create_paragraph_bullets': + return 'google_docs_create_paragraph_bullets' + case 'delete_paragraph_bullets': + return 'google_docs_delete_paragraph_bullets' + case 'delete_content_range': + return 'google_docs_delete_content_range' + case 'create_named_range': + return 'google_docs_create_named_range' + case 'delete_named_range': + return 'google_docs_delete_named_range' default: throw new Error(`Invalid Google Docs operation: ${params.operation}`) } @@ -436,6 +563,12 @@ Return ONLY the text to insert - no explanations, no extra text.`, italic: { type: 'boolean', description: 'Apply italic styling' }, underline: { type: 'boolean', description: 'Apply underline styling' }, fontSize: { type: 'number', description: 'Font size in points' }, + namedStyleType: { type: 'string', description: 'Named paragraph style to apply' }, + alignment: { type: 'string', description: 'Paragraph alignment to apply' }, + bulletPreset: { type: 'string', description: 'Bullet glyph preset to apply' }, + name: { type: 'string', description: 'Name for a created named range' }, + namedRangeId: { type: 'string', description: 'ID of a named range to delete' }, + namedRangeName: { type: 'string', description: 'Name of named range(s) to delete' }, }, outputs: { content: { type: 'string', description: 'Document content' }, @@ -446,6 +579,7 @@ Return ONLY the text to insert - no explanations, no extra text.`, description: 'Number of occurrences replaced during find & replace', }, objectId: { type: 'string', description: 'ID of an inserted inline image object' }, + namedRangeId: { type: 'string', description: 'ID of a created named range' }, }, } diff --git a/apps/sim/blocks/blocks/jira.ts b/apps/sim/blocks/blocks/jira.ts index b9c61b3cd3d..943a01e9f4d 100644 --- a/apps/sim/blocks/blocks/jira.ts +++ b/apps/sim/blocks/blocks/jira.ts @@ -50,6 +50,11 @@ export const JiraBlock: BlockConfig = { { label: 'Remove Watcher', id: 'remove_watcher' }, { label: 'Get Users', id: 'get_users' }, { label: 'Search Users', id: 'search_users' }, + { label: 'List Projects', id: 'list_projects' }, + { label: 'Get Project', id: 'get_project' }, + { label: 'Get Transitions', id: 'get_transitions' }, + { label: 'List Issue Types', id: 'list_issue_types' }, + { label: 'Get Fields', id: 'get_fields' }, ], value: () => 'read', }, @@ -91,7 +96,7 @@ export const JiraBlock: BlockConfig = { placeholder: 'Select Jira project', dependsOn: ['credential', 'domain'], mode: 'basic', - required: { field: 'operation', value: ['write', 'read-bulk'] }, + required: { field: 'operation', value: ['write', 'read-bulk', 'get_project'] }, }, // Manual project ID input (advanced mode) { @@ -102,7 +107,7 @@ export const JiraBlock: BlockConfig = { placeholder: 'Enter Jira project ID', dependsOn: ['credential', 'domain'], mode: 'advanced', - required: { field: 'operation', value: ['write', 'read-bulk'] }, + required: { field: 'operation', value: ['write', 'read-bulk', 'get_project'] }, }, // Issue selector (basic mode) { @@ -134,6 +139,7 @@ export const JiraBlock: BlockConfig = { 'delete_worklog', 'add_watcher', 'remove_watcher', + 'get_transitions', ], }, required: { @@ -156,6 +162,7 @@ export const JiraBlock: BlockConfig = { 'delete_worklog', 'add_watcher', 'remove_watcher', + 'get_transitions', ], }, mode: 'basic', @@ -188,6 +195,7 @@ export const JiraBlock: BlockConfig = { 'delete_worklog', 'add_watcher', 'remove_watcher', + 'get_transitions', ], }, required: { @@ -210,6 +218,7 @@ export const JiraBlock: BlockConfig = { 'delete_worklog', 'add_watcher', 'remove_watcher', + 'get_transitions', ], }, mode: 'advanced', @@ -715,6 +724,30 @@ Return ONLY the comment text - no explanations.`, condition: { field: 'operation', value: 'search_users' }, mode: 'advanced', }, + // List Projects fields + { + id: 'projectSearchQuery', + title: 'Project Filter', + type: 'short-input', + placeholder: 'Filter projects by name or key (optional)', + condition: { field: 'operation', value: 'list_projects' }, + }, + { + id: 'listProjectsStartAt', + title: 'Start At', + type: 'short-input', + placeholder: 'Pagination start index (default: 0)', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, + { + id: 'listProjectsMaxResults', + title: 'Max Results', + type: 'short-input', + placeholder: 'Maximum projects to return (default: 50, max: 100)', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, // Trigger SubBlocks ...getTrigger('jira_issue_created').subBlocks, ...getTrigger('jira_issue_updated').subBlocks, @@ -759,6 +792,11 @@ Return ONLY the comment text - no explanations.`, 'jira_remove_watcher', 'jira_get_users', 'jira_search_users', + 'jira_list_projects', + 'jira_get_project', + 'jira_get_transitions', + 'jira_list_issue_types', + 'jira_get_fields', ], config: { tool: (params) => { @@ -813,6 +851,16 @@ Return ONLY the comment text - no explanations.`, return 'jira_get_users' case 'search_users': return 'jira_search_users' + case 'list_projects': + return 'jira_list_projects' + case 'get_project': + return 'jira_get_project' + case 'get_transitions': + return 'jira_get_transitions' + case 'list_issue_types': + return 'jira_list_issue_types' + case 'get_fields': + return 'jira_get_fields' default: return 'jira_retrieve' } @@ -1108,6 +1156,40 @@ Return ONLY the comment text - no explanations.`, : undefined, } } + case 'list_projects': { + return { + ...baseParams, + query: params.projectSearchQuery || undefined, + startAt: params.listProjectsStartAt + ? Number.parseInt(params.listProjectsStartAt) + : undefined, + maxResults: params.listProjectsMaxResults + ? Number.parseInt(params.listProjectsMaxResults) + : undefined, + } + } + case 'get_project': { + return { + ...baseParams, + projectId: effectiveProjectId, + } + } + case 'get_transitions': { + return { + ...baseParams, + issueKey: effectiveIssueKey, + } + } + case 'list_issue_types': { + return { + ...baseParams, + } + } + case 'get_fields': { + return { + ...baseParams, + } + } default: return baseParams } @@ -1198,6 +1280,19 @@ Return ONLY the comment text - no explanations.`, }, searchUsersMaxResults: { type: 'string', description: 'Maximum users to return from search' }, searchUsersStartAt: { type: 'string', description: 'Pagination start index for user search' }, + // List Projects operation inputs + projectSearchQuery: { + type: 'string', + description: 'Filter projects by partial name or key match', + }, + listProjectsStartAt: { + type: 'string', + description: 'Pagination start index for listing projects', + }, + listProjectsMaxResults: { + type: 'string', + description: 'Maximum projects to return when listing', + }, }, outputs: { // Common outputs across all Jira operations @@ -1285,6 +1380,31 @@ Return ONLY the comment text - no explanations.`, description: 'Array of users with accountId, displayName, emailAddress, active status', }, + // jira_list_projects outputs + projects: { + type: 'json', + description: 'Array of projects with id, key, name, projectTypeKey, and lead', + }, + + // jira_get_project / jira_list_issue_types outputs + projectTypeKey: { type: 'string', description: 'Project type key (e.g., software, business)' }, + issueTypes: { + type: 'json', + description: 'Array of issue types with id, name, description, subtask, hierarchyLevel', + }, + + // jira_get_transitions outputs + transitions: { + type: 'json', + description: 'Array of available workflow transitions with id, name, and target status', + }, + + // jira_get_fields outputs + fields: { + type: 'json', + description: 'Array of Jira fields with id, key, name, custom flag, and schema type', + }, + // jira_bulk_read outputs // Note: bulk_read returns an array in the output field, each item contains: // ts, issueKey, summary, description, status, assignee, created, updated diff --git a/apps/sim/blocks/blocks/monday.ts b/apps/sim/blocks/blocks/monday.ts index 1753a05c5ba..a7d95ccb875 100644 --- a/apps/sim/blocks/blocks/monday.ts +++ b/apps/sim/blocks/blocks/monday.ts @@ -4,12 +4,17 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import type { MondayArchiveItemResponse, + MondayChangeColumnValueResponse, + MondayCreateBoardResponse, + MondayCreateColumnResponse, MondayCreateGroupResponse, MondayCreateItemResponse, MondayCreateSubitemResponse, MondayCreateUpdateResponse, MondayDeleteItemResponse, + MondayDuplicateItemResponse, MondayGetBoardResponse, + MondayGetGroupsResponse, MondayGetItemResponse, MondayGetItemsResponse, MondayListBoardsResponse, @@ -33,6 +38,11 @@ type MondayResponse = | MondaySearchItemsResponse | MondayCreateSubitemResponse | MondayMoveItemToGroupResponse + | MondayChangeColumnValueResponse + | MondayCreateBoardResponse + | MondayCreateColumnResponse + | MondayGetGroupsResponse + | MondayDuplicateItemResponse const BOARD_OPS = [ 'get_board', @@ -41,6 +51,10 @@ const BOARD_OPS = [ 'create_item', 'update_item', 'create_group', + 'get_groups', + 'create_column', + 'change_column_value', + 'duplicate_item', ] const ITEM_ID_OPS = [ @@ -50,6 +64,8 @@ const ITEM_ID_OPS = [ 'archive_item', 'create_update', 'move_item_to_group', + 'change_column_value', + 'duplicate_item', ] export const MondayBlock: BlockConfig = { @@ -77,12 +93,17 @@ export const MondayBlock: BlockConfig = { { label: 'Search Items', id: 'search_items' }, { label: 'Create Item', id: 'create_item' }, { label: 'Update Item', id: 'update_item' }, + { label: 'Change Column Value', id: 'change_column_value' }, + { label: 'Duplicate Item', id: 'duplicate_item' }, { label: 'Delete Item', id: 'delete_item' }, { label: 'Archive Item', id: 'archive_item' }, { label: 'Move Item to Group', id: 'move_item_to_group' }, { label: 'Create Subitem', id: 'create_subitem' }, { label: 'Create Update', id: 'create_update' }, { label: 'Create Group', id: 'create_group' }, + { label: 'Get Groups', id: 'get_groups' }, + { label: 'Create Board', id: 'create_board' }, + { label: 'Create Column', id: 'create_column' }, ], value: () => 'list_boards', }, @@ -243,6 +264,142 @@ export const MondayBlock: BlockConfig = { mode: 'advanced', condition: { field: 'operation', value: 'create_group' }, }, + { + id: 'columnId', + title: 'Column ID', + type: 'short-input', + placeholder: 'Enter column ID (e.g., status)', + condition: { field: 'operation', value: 'change_column_value' }, + required: { field: 'operation', value: 'change_column_value' }, + }, + { + id: 'columnValue', + title: 'Column Value', + type: 'long-input', + placeholder: '{"label":"Done"}', + condition: { field: 'operation', value: 'change_column_value' }, + required: { field: 'operation', value: 'change_column_value' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON value for a single Monday.com column. The shape depends on the column type (e.g., {"label":"Done"} for status, {"date":"2024-01-01"} for date). Return ONLY the JSON value - no explanations, no extra text.', + generationType: 'json-object', + }, + }, + { + id: 'createLabelsIfMissing', + title: 'Create Labels If Missing', + type: 'switch', + mode: 'advanced', + condition: { field: 'operation', value: 'change_column_value' }, + }, + { + id: 'withUpdates', + title: 'Include Updates', + type: 'switch', + mode: 'advanced', + condition: { field: 'operation', value: 'duplicate_item' }, + }, + { + id: 'boardName', + title: 'Board Name', + type: 'short-input', + placeholder: 'Enter board name', + condition: { field: 'operation', value: 'create_board' }, + required: { field: 'operation', value: 'create_board' }, + }, + { + id: 'boardKind', + title: 'Board Kind', + type: 'dropdown', + options: [ + { label: 'Public', id: 'public' }, + { label: 'Private', id: 'private' }, + { label: 'Shareable', id: 'share' }, + ], + value: () => 'public', + condition: { field: 'operation', value: 'create_board' }, + required: { field: 'operation', value: 'create_board' }, + }, + { + id: 'boardDescription', + title: 'Board Description', + type: 'long-input', + placeholder: 'Enter board description', + mode: 'advanced', + condition: { field: 'operation', value: 'create_board' }, + }, + { + id: 'workspaceId', + title: 'Workspace ID', + type: 'short-input', + placeholder: 'Enter workspace ID', + mode: 'advanced', + condition: { field: 'operation', value: 'create_board' }, + }, + { + id: 'folderId', + title: 'Folder ID', + type: 'short-input', + placeholder: 'Enter folder ID', + mode: 'advanced', + condition: { field: 'operation', value: 'create_board' }, + }, + { + id: 'columnTitle', + title: 'Column Title', + type: 'short-input', + placeholder: 'Enter column title', + condition: { field: 'operation', value: 'create_column' }, + required: { field: 'operation', value: 'create_column' }, + }, + { + id: 'columnType', + title: 'Column Type', + type: 'dropdown', + options: [ + { label: 'Status', id: 'status' }, + { label: 'Text', id: 'text' }, + { label: 'Long Text', id: 'long_text' }, + { label: 'Numbers', id: 'numbers' }, + { label: 'Date', id: 'date' }, + { label: 'People', id: 'people' }, + { label: 'Dropdown', id: 'dropdown' }, + { label: 'Checkbox', id: 'checkbox' }, + { label: 'Email', id: 'email' }, + { label: 'Phone', id: 'phone' }, + { label: 'Link', id: 'link' }, + { label: 'Timeline', id: 'timeline' }, + { label: 'Tags', id: 'tags' }, + { label: 'Rating', id: 'rating' }, + { label: 'Country', id: 'country' }, + ], + value: () => 'text', + condition: { field: 'operation', value: 'create_column' }, + required: { field: 'operation', value: 'create_column' }, + }, + { + id: 'columnDescription', + title: 'Column Description', + type: 'long-input', + placeholder: 'Enter column description', + mode: 'advanced', + condition: { field: 'operation', value: 'create_column' }, + }, + { + id: 'columnDefaults', + title: 'Column Defaults', + type: 'long-input', + placeholder: '{"labels":{"0":"To Do","1":"Done"}}', + mode: 'advanced', + condition: { field: 'operation', value: 'create_column' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON object of default settings for a Monday.com column (e.g., status labels). Return ONLY the JSON object string - no explanations, no extra text.', + generationType: 'json-object', + }, + }, { id: 'limit', title: 'Limit', @@ -289,12 +446,17 @@ export const MondayBlock: BlockConfig = { 'monday_search_items', 'monday_create_item', 'monday_update_item', + 'monday_change_column_value', + 'monday_duplicate_item', 'monday_delete_item', 'monday_archive_item', 'monday_move_item_to_group', 'monday_create_subitem', 'monday_create_update', 'monday_create_group', + 'monday_get_groups', + 'monday_create_board', + 'monday_create_column', ], config: { tool: (params) => { @@ -348,6 +510,42 @@ export const MondayBlock: BlockConfig = { itemId: params.itemId, columnValues: params.columnValues, } + case 'change_column_value': + return { + ...baseParams, + boardId: params.boardId, + itemId: params.itemId, + columnId: params.columnId, + value: params.columnValue, + createLabelsIfMissing: Boolean(params.createLabelsIfMissing), + } + case 'duplicate_item': + return { + ...baseParams, + boardId: params.boardId, + itemId: params.itemId, + withUpdates: Boolean(params.withUpdates), + } + case 'create_board': + return { + ...baseParams, + boardName: params.boardName, + boardKind: params.boardKind || 'public', + description: params.boardDescription || undefined, + workspaceId: params.workspaceId || undefined, + folderId: params.folderId || undefined, + } + case 'create_column': + return { + ...baseParams, + boardId: params.boardId, + columnTitle: params.columnTitle, + columnType: params.columnType || 'text', + columnDescription: params.columnDescription || undefined, + columnDefaults: params.columnDefaults || undefined, + } + case 'get_groups': + return { ...baseParams, boardId: params.boardId } case 'delete_item': return { ...baseParams, itemId: params.itemId } case 'archive_item': @@ -394,6 +592,22 @@ export const MondayBlock: BlockConfig = { groupId: { type: 'string', description: 'Group ID' }, searchColumns: { type: 'string', description: 'JSON array of column filters for search' }, columnValues: { type: 'string', description: 'JSON string of column values' }, + columnId: { type: 'string', description: 'Single column ID to change' }, + columnValue: { type: 'string', description: 'JSON value for a single column' }, + createLabelsIfMissing: { + type: 'boolean', + description: 'Create status/dropdown labels that do not yet exist', + }, + withUpdates: { type: 'boolean', description: 'Include item updates when duplicating' }, + boardName: { type: 'string', description: 'Board name for creation' }, + boardKind: { type: 'string', description: 'Board kind (public, private, share)' }, + boardDescription: { type: 'string', description: 'Board description' }, + workspaceId: { type: 'string', description: 'Workspace ID for board creation' }, + folderId: { type: 'string', description: 'Folder ID for board creation' }, + columnTitle: { type: 'string', description: 'Column title for creation' }, + columnType: { type: 'string', description: 'Column type for creation' }, + columnDescription: { type: 'string', description: 'Column description' }, + columnDefaults: { type: 'string', description: 'JSON defaults for the new column' }, updateBody: { type: 'string', description: 'Update text content' }, groupName: { type: 'string', description: 'Group name' }, groupColor: { type: 'string', description: 'Group color hex code' }, @@ -412,18 +626,23 @@ export const MondayBlock: BlockConfig = { type: 'json', description: 'Board details (id, name, description, state, boardKind, itemsCount, url, updatedAt)', - condition: { field: 'operation', value: 'get_board' }, + condition: { field: 'operation', value: ['get_board', 'create_board'] }, }, groups: { type: 'json', description: 'Board groups (id, title, color, archived, deleted, position)', - condition: { field: 'operation', value: 'get_board' }, + condition: { field: 'operation', value: ['get_board', 'get_groups'] }, }, columns: { type: 'json', description: 'Board columns (id, title, type)', condition: { field: 'operation', value: 'get_board' }, }, + column: { + type: 'json', + description: 'Created column (id, title, type)', + condition: { field: 'operation', value: 'create_column' }, + }, items: { type: 'json', description: @@ -436,7 +655,15 @@ export const MondayBlock: BlockConfig = { 'Item details (id, name, state, boardId, groupId, groupTitle, columnValues, createdAt, updatedAt, url)', condition: { field: 'operation', - value: ['get_item', 'create_item', 'update_item', 'create_subitem', 'move_item_to_group'], + value: [ + 'get_item', + 'create_item', + 'update_item', + 'create_subitem', + 'move_item_to_group', + 'change_column_value', + 'duplicate_item', + ], }, }, id: { @@ -457,7 +684,10 @@ export const MondayBlock: BlockConfig = { count: { type: 'number', description: 'Number of returned results', - condition: { field: 'operation', value: ['list_boards', 'get_items', 'search_items'] }, + condition: { + field: 'operation', + value: ['list_boards', 'get_items', 'search_items', 'get_groups'], + }, }, cursor: { type: 'string', diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index bb663157a91..ae3da995b79 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -62,6 +62,13 @@ export const SlackBlock: BlockConfig = { { label: 'Update View', id: 'update_view' }, { label: 'Push View', id: 'push_view' }, { label: 'Publish View', id: 'publish_view' }, + { label: 'Schedule Message', id: 'schedule_message' }, + { label: 'List Scheduled Messages', id: 'list_scheduled_messages' }, + { label: 'Delete Scheduled Message', id: 'delete_scheduled_message' }, + { label: 'Archive Conversation', id: 'archive_conversation' }, + { label: 'Rename Conversation', id: 'rename_conversation' }, + { label: 'Set Conversation Topic', id: 'set_conversation_topic' }, + { label: 'Set Conversation Purpose', id: 'set_conversation_purpose' }, ], value: () => 'send', }, @@ -87,7 +94,7 @@ export const SlackBlock: BlockConfig = { value: () => 'channel', condition: { field: 'operation', - value: ['send', 'read'], + value: ['send', 'read', 'schedule_message'], }, }, { @@ -175,7 +182,7 @@ export const SlackBlock: BlockConfig = { }, required: { field: 'operation', - value: 'list_canvases', + value: ['list_canvases', 'list_scheduled_messages'], not: true, }, }, @@ -219,7 +226,7 @@ export const SlackBlock: BlockConfig = { }, required: { field: 'operation', - value: 'list_canvases', + value: ['list_canvases', 'list_scheduled_messages'], not: true, }, }, @@ -294,7 +301,7 @@ export const SlackBlock: BlockConfig = { value: () => 'text', condition: { field: 'operation', - value: ['send', 'ephemeral', 'update'], + value: ['send', 'ephemeral', 'update', 'schedule_message'], }, }, { @@ -304,12 +311,12 @@ export const SlackBlock: BlockConfig = { placeholder: 'Enter your message (supports Slack mrkdwn)', condition: { field: 'operation', - value: ['send', 'ephemeral'], + value: ['send', 'ephemeral', 'schedule_message'], and: { field: 'messageFormat', value: 'blocks', not: true }, }, required: { field: 'operation', - value: ['send', 'ephemeral'], + value: ['send', 'ephemeral', 'schedule_message'], and: { field: 'messageFormat', value: 'blocks', not: true }, }, }, @@ -321,12 +328,12 @@ export const SlackBlock: BlockConfig = { placeholder: 'JSON array of Block Kit blocks', condition: { field: 'operation', - value: ['send', 'ephemeral', 'update'], + value: ['send', 'ephemeral', 'update', 'schedule_message'], and: { field: 'messageFormat', value: 'blocks' }, }, required: { field: 'operation', - value: ['send', 'ephemeral', 'update'], + value: ['send', 'ephemeral', 'update', 'schedule_message'], and: { field: 'messageFormat', value: 'blocks' }, }, wandConfig: { @@ -383,7 +390,7 @@ Do not include any explanations, markdown formatting, or other text outside the placeholder: 'Reply to thread (e.g., 1405894322.002768)', condition: { field: 'operation', - value: ['send', 'ephemeral'], + value: ['send', 'ephemeral', 'schedule_message'], }, required: false, }, @@ -1344,6 +1351,105 @@ Do not include any explanations, markdown formatting, or other text outside the placeholder: 'Describe the view/modal you want to create...', }, }, + // Schedule Message specific fields + { + id: 'scheduleAt', + title: 'Send At', + type: 'short-input', + placeholder: 'Unix timestamp in seconds (e.g., 1700000000)', + condition: { + field: 'operation', + value: 'schedule_message', + }, + required: true, + wandConfig: { + enabled: true, + prompt: `Generate a Unix timestamp in seconds based on the user's description. +The timestamp must represent a time in the future (Slack rejects past times and times more than 120 days out). +Examples: +- "in 1 hour" -> current Unix time + 3600 +- "tomorrow at 9am" -> Unix timestamp for tomorrow 09:00 local time +- "next Monday" -> Unix timestamp for the next Monday at 00:00 + +If the input looks like a reference to another block's output (contains < and >) or is already a numeric Unix timestamp, return it as-is. +Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra text.`, + placeholder: 'Describe when to send (e.g., "in 2 hours", "tomorrow at 9am")...', + generationType: 'timestamp', + }, + }, + // List Scheduled Messages specific fields + { + id: 'scheduledLimit', + title: 'Message Limit', + type: 'short-input', + placeholder: '100', + condition: { + field: 'operation', + value: 'list_scheduled_messages', + }, + mode: 'advanced', + required: false, + }, + { + id: 'scheduledCursor', + title: 'Pagination Cursor', + type: 'short-input', + placeholder: 'next_cursor from a previous response', + condition: { + field: 'operation', + value: 'list_scheduled_messages', + }, + mode: 'advanced', + required: false, + }, + // Delete Scheduled Message specific fields + { + id: 'scheduledMessageId', + title: 'Scheduled Message ID', + type: 'short-input', + placeholder: 'Scheduled message ID (e.g., Q1234ABCD)', + condition: { + field: 'operation', + value: 'delete_scheduled_message', + }, + required: true, + }, + // Rename Conversation specific fields + { + id: 'renameChannelName', + title: 'New Channel Name', + type: 'short-input', + placeholder: 'e.g., project-updates (max 80 chars)', + condition: { + field: 'operation', + value: 'rename_conversation', + }, + required: true, + }, + // Set Conversation Topic specific fields + { + id: 'conversationTopic', + title: 'Topic', + type: 'long-input', + placeholder: 'New channel topic (max 250 characters)', + condition: { + field: 'operation', + value: 'set_conversation_topic', + }, + required: true, + }, + // Set Conversation Purpose specific fields + { + id: 'conversationPurpose', + title: 'Purpose', + type: 'long-input', + placeholder: 'New channel purpose/description (max 250 characters)', + condition: { + field: 'operation', + value: 'set_conversation_purpose', + }, + required: true, + }, ...getTrigger('slack_webhook').subBlocks, ], tools: { @@ -1383,6 +1489,13 @@ Do not include any explanations, markdown formatting, or other text outside the 'slack_update_view', 'slack_push_view', 'slack_publish_view', + 'slack_schedule_message', + 'slack_list_scheduled_messages', + 'slack_delete_scheduled_message', + 'slack_archive_conversation', + 'slack_rename_conversation', + 'slack_set_conversation_topic', + 'slack_set_conversation_purpose', ], config: { tool: (params) => { @@ -1457,6 +1570,20 @@ Do not include any explanations, markdown formatting, or other text outside the return 'slack_push_view' case 'publish_view': return 'slack_publish_view' + case 'schedule_message': + return 'slack_schedule_message' + case 'list_scheduled_messages': + return 'slack_list_scheduled_messages' + case 'delete_scheduled_message': + return 'slack_delete_scheduled_message' + case 'archive_conversation': + return 'slack_archive_conversation' + case 'rename_conversation': + return 'slack_rename_conversation' + case 'set_conversation_topic': + return 'slack_set_conversation_topic' + case 'set_conversation_purpose': + return 'slack_set_conversation_purpose' default: throw new Error(`Invalid Slack operation: ${params.operation}`) } @@ -1539,6 +1666,13 @@ Do not include any explanations, markdown formatting, or other text outside the fileId, fileName, paginationCursor, + scheduleAt, + scheduledLimit, + scheduledCursor, + scheduledMessageId, + renameChannelName, + conversationTopic, + conversationPurpose, ...rest } = params @@ -1552,6 +1686,9 @@ Do not include any explanations, markdown formatting, or other text outside the if (isDM && dmSupportedOperations.includes(operation)) { baseParams.userId = effectiveUserId + } else if (isDM && operation === 'schedule_message' && effectiveUserId) { + // chat.scheduleMessage opens a DM when the channel is set to a user ID + baseParams.channel = effectiveUserId } else if (effectiveChannel) { baseParams.channel = effectiveChannel } @@ -1869,6 +2006,54 @@ Do not include any explanations, markdown formatting, or other text outside the } baseParams.view = viewPayload break + + case 'schedule_message': { + baseParams.text = messageFormat === 'blocks' && !text ? ' ' : text + if (blocks) { + baseParams.blocks = blocks + } + if (threadTs) { + baseParams.threadTs = threadTs + } + const parsedPostAt = Number.parseInt(String(scheduleAt ?? '').trim(), 10) + if (Number.isNaN(parsedPostAt)) { + throw new Error('Send At must be a Unix timestamp in seconds') + } + baseParams.postAt = parsedPostAt + break + } + + case 'list_scheduled_messages': { + if (scheduledLimit) { + const parsedLimit = Number.parseInt(scheduledLimit, 10) + if (!Number.isNaN(parsedLimit) && parsedLimit > 0) { + baseParams.limit = parsedLimit + } + } + if (scheduledCursor) { + baseParams.cursor = String(scheduledCursor).trim() + } + break + } + + case 'delete_scheduled_message': + baseParams.scheduledMessageId = scheduledMessageId + break + + case 'archive_conversation': + break + + case 'rename_conversation': + baseParams.name = renameChannelName + break + + case 'set_conversation_topic': + baseParams.topic = conversationTopic + break + + case 'set_conversation_purpose': + baseParams.purpose = conversationPurpose + break } return baseParams @@ -2012,6 +2197,28 @@ Do not include any explanations, markdown formatting, or other text outside the description: 'User ID to publish Home tab view to', }, viewPayload: { type: 'json', description: 'View payload object with type, title, and blocks' }, + // Schedule Message inputs + scheduleAt: { + type: 'string', + description: 'Unix timestamp (seconds) for when the scheduled message should post', + }, + // List Scheduled Messages inputs + scheduledLimit: { + type: 'string', + description: 'Maximum number of scheduled messages to return', + }, + scheduledCursor: { type: 'string', description: 'Pagination cursor for scheduled messages' }, + // Delete Scheduled Message inputs + scheduledMessageId: { type: 'string', description: 'Scheduled message ID to delete' }, + // Rename Conversation inputs + renameChannelName: { type: 'string', description: 'New name for the channel' }, + // Set Conversation Topic inputs + conversationTopic: { type: 'string', description: 'New channel topic (max 250 characters)' }, + // Set Conversation Purpose inputs + conversationPurpose: { + type: 'string', + description: 'New channel purpose/description (max 250 characters)', + }, }, outputs: { // slack_message outputs (send operation) @@ -2199,6 +2406,29 @@ Do not include any explanations, markdown formatting, or other text outside the 'Array of per-user error objects when force is true and some invitations failed (user, ok, error)', }, + // slack_schedule_message outputs (schedule_message operation) + scheduledMessageId: { + type: 'string', + description: 'Identifier of the scheduled message (used to delete it before it posts)', + }, + postAt: { + type: 'number', + description: 'Unix timestamp when a scheduled message will post', + }, + + // slack_list_scheduled_messages outputs (list_scheduled_messages operation) + scheduledMessages: { + type: 'json', + description: + 'Array of pending scheduled message objects with properties: id, channel_id, post_at, date_created, text', + }, + + // slack_set_conversation_purpose outputs (set_conversation_purpose operation) + purpose: { + type: 'string', + description: 'The purpose/description that was set on the channel', + }, + // Trigger outputs (when used as webhook trigger) event_type: { type: 'string', description: 'Type of Slack event that triggered the workflow' }, subtype: { diff --git a/apps/sim/blocks/blocks/trello.ts b/apps/sim/blocks/blocks/trello.ts index 9cb459895e4..371ab9dcaa9 100644 --- a/apps/sim/blocks/blocks/trello.ts +++ b/apps/sim/blocks/blocks/trello.ts @@ -73,9 +73,16 @@ export const TrelloBlock: BlockConfig = { { label: 'Get Lists', id: 'trello_list_lists' }, { label: 'List Cards', id: 'trello_list_cards' }, { label: 'Create Card', id: 'trello_create_card' }, + { label: 'Get Card', id: 'trello_get_card' }, { label: 'Update Card', id: 'trello_update_card' }, { label: 'Get Actions', id: 'trello_get_actions' }, { label: 'Add Comment', id: 'trello_add_comment' }, + { label: 'Add Checklist', id: 'trello_add_checklist' }, + { label: 'Add Label', id: 'trello_add_label' }, + { label: 'Add Member', id: 'trello_add_member' }, + { label: 'Create Board', id: 'trello_create_board' }, + { label: 'Get Board', id: 'trello_get_board' }, + { label: 'Create List', id: 'trello_create_list' }, ], value: () => 'trello_list_lists', }, @@ -111,11 +118,17 @@ export const TrelloBlock: BlockConfig = { mode: 'basic', condition: { field: 'operation', - value: ['trello_list_lists', 'trello_list_cards', 'trello_get_actions'], + value: [ + 'trello_list_lists', + 'trello_list_cards', + 'trello_get_actions', + 'trello_get_board', + 'trello_create_list', + ], }, required: { field: 'operation', - value: 'trello_list_lists', + value: ['trello_list_lists', 'trello_get_board', 'trello_create_list'], }, }, { @@ -128,11 +141,17 @@ export const TrelloBlock: BlockConfig = { mode: 'advanced', condition: { field: 'operation', - value: ['trello_list_lists', 'trello_list_cards', 'trello_get_actions'], + value: [ + 'trello_list_lists', + 'trello_list_cards', + 'trello_get_actions', + 'trello_get_board', + 'trello_create_list', + ], }, required: { field: 'operation', - value: 'trello_list_lists', + value: ['trello_list_lists', 'trello_get_board', 'trello_create_list'], }, }, { @@ -156,11 +175,26 @@ export const TrelloBlock: BlockConfig = { placeholder: 'Enter Trello card ID', condition: { field: 'operation', - value: ['trello_update_card', 'trello_get_actions', 'trello_add_comment'], + value: [ + 'trello_update_card', + 'trello_get_actions', + 'trello_add_comment', + 'trello_get_card', + 'trello_add_checklist', + 'trello_add_label', + 'trello_add_member', + ], }, required: { field: 'operation', - value: ['trello_update_card', 'trello_add_comment'], + value: [ + 'trello_update_card', + 'trello_add_comment', + 'trello_get_card', + 'trello_add_checklist', + 'trello_add_label', + 'trello_add_member', + ], }, }, { @@ -327,6 +361,120 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, }, required: true, }, + { + id: 'boardName', + title: 'Board Name', + type: 'short-input', + placeholder: 'Enter board name', + condition: { + field: 'operation', + value: 'trello_create_board', + }, + required: true, + }, + { + id: 'boardDesc', + title: 'Description', + type: 'long-input', + placeholder: 'Enter board description', + condition: { + field: 'operation', + value: 'trello_create_board', + }, + }, + { + id: 'idOrganization', + title: 'Workspace ID', + type: 'short-input', + placeholder: 'Enter workspace/organization ID or name', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_create_board', + }, + }, + { + id: 'defaultLists', + title: 'Default Lists', + type: 'dropdown', + options: [ + { label: 'Leave Unset', id: '' }, + { label: 'Create Default Lists', id: 'true' }, + { label: 'No Default Lists', id: 'false' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_create_board', + }, + }, + { + id: 'listName', + title: 'List Name', + type: 'short-input', + placeholder: 'Enter list name', + condition: { + field: 'operation', + value: 'trello_create_list', + }, + required: true, + }, + { + id: 'listPos', + title: 'List Position', + type: 'short-input', + placeholder: 'top, bottom, or a positive float', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_create_list', + }, + }, + { + id: 'checklistName', + title: 'Checklist Name', + type: 'short-input', + placeholder: 'Enter checklist name', + condition: { + field: 'operation', + value: 'trello_add_checklist', + }, + required: true, + }, + { + id: 'checklistPos', + title: 'Checklist Position', + type: 'short-input', + placeholder: 'top, bottom, or a positive float', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_add_checklist', + }, + }, + { + id: 'labelId', + title: 'Label ID', + type: 'short-input', + placeholder: 'Enter Trello label ID', + condition: { + field: 'operation', + value: 'trello_add_label', + }, + required: true, + }, + { + id: 'memberId', + title: 'Member ID', + type: 'short-input', + placeholder: 'Enter Trello member ID', + condition: { + field: 'operation', + value: 'trello_add_member', + }, + required: true, + }, ], tools: { access: [ @@ -336,6 +484,13 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, 'trello_update_card', 'trello_get_actions', 'trello_add_comment', + 'trello_create_board', + 'trello_get_board', + 'trello_create_list', + 'trello_get_card', + 'trello_add_checklist', + 'trello_add_label', + 'trello_add_member', ], config: { tool: (params) => getTrimmedString(params.operation) ?? 'trello_list_lists', @@ -462,6 +617,126 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, } } + case 'trello_create_board': { + const name = getTrimmedString(params.boardName) + + if (!name) { + throw new Error('Board name is required.') + } + + return { + ...baseParams, + name, + desc: getTrimmedString(params.boardDesc), + idOrganization: getTrimmedString(params.idOrganization), + defaultLists: parseOptionalBooleanInput(params.defaultLists), + } + } + + case 'trello_get_board': { + const boardId = getTrimmedString(params.boardId) + + if (!boardId) { + throw new Error('Board ID is required.') + } + + return { + ...baseParams, + boardId, + } + } + + case 'trello_create_list': { + const boardId = getTrimmedString(params.boardId) + const name = getTrimmedString(params.listName) + + if (!boardId) { + throw new Error('Board ID is required.') + } + + if (!name) { + throw new Error('List name is required.') + } + + return { + ...baseParams, + boardId, + name, + pos: getTrimmedString(params.listPos), + } + } + + case 'trello_get_card': { + const cardId = getTrimmedString(params.cardId) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + return { + ...baseParams, + cardId, + } + } + + case 'trello_add_checklist': { + const cardId = getTrimmedString(params.cardId) + const name = getTrimmedString(params.checklistName) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + if (!name) { + throw new Error('Checklist name is required.') + } + + return { + ...baseParams, + cardId, + name, + pos: getTrimmedString(params.checklistPos), + } + } + + case 'trello_add_label': { + const cardId = getTrimmedString(params.cardId) + const labelId = getTrimmedString(params.labelId) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + if (!labelId) { + throw new Error('Label ID is required.') + } + + return { + ...baseParams, + cardId, + labelId, + } + } + + case 'trello_add_member': { + const cardId = getTrimmedString(params.cardId) + const memberId = getTrimmedString(params.memberId) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + if (!memberId) { + throw new Error('Member ID is required.') + } + + return { + ...baseParams, + cardId, + memberId, + } + } + default: return baseParams } @@ -489,6 +764,25 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, limit: { type: 'number', description: 'Maximum number of board actions to return' }, page: { type: 'number', description: 'Page number for action results' }, text: { type: 'string', description: 'Comment text' }, + boardName: { type: 'string', description: 'Board name' }, + boardDesc: { type: 'string', description: 'Board description' }, + idOrganization: { + type: 'string', + description: 'Workspace/organization ID or name for a new board', + }, + defaultLists: { + type: 'boolean', + description: 'Whether to create default lists on a new board', + }, + listName: { type: 'string', description: 'List name' }, + listPos: { type: 'string', description: 'List position (top, bottom, or positive float)' }, + checklistName: { type: 'string', description: 'Checklist name' }, + checklistPos: { + type: 'string', + description: 'Checklist position (top, bottom, or positive float)', + }, + labelId: { type: 'string', description: 'Label ID to attach to a card' }, + memberId: { type: 'string', description: 'Member ID to assign to a card' }, }, outputs: { lists: { @@ -503,7 +797,27 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, card: { type: 'json', description: - 'Created or updated card (id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete)', + 'Created, updated, or fetched card (id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete)', + }, + board: { + type: 'json', + description: 'Created or fetched board (id, name, desc, url, closed, idOrganization)', + }, + list: { + type: 'json', + description: 'Created list (id, name, closed, pos, idBoard)', + }, + checklist: { + type: 'json', + description: 'Created checklist (id, name, idCard, idBoard, pos)', + }, + labelIds: { + type: 'json', + description: 'Label IDs applied to a card after adding a label', + }, + memberIds: { + type: 'json', + description: 'Member IDs assigned to a card after adding a member', }, actions: { type: 'json', diff --git a/apps/sim/lib/api/contracts/tools/asana.ts b/apps/sim/lib/api/contracts/tools/asana.ts index e51d8c1e8ff..5364dda722e 100644 --- a/apps/sim/lib/api/contracts/tools/asana.ts +++ b/apps/sim/lib/api/contracts/tools/asana.ts @@ -79,6 +79,66 @@ const asanaGetTaskResponseSchema = z.union([ asanaTasksResponseSchema, ]) +const asanaProjectRecordResponseSchema = z.object({ + success: z.literal(true), + ts: z.string(), + gid: z.string(), + name: z.string(), + notes: z.string(), + archived: z.boolean().optional(), + color: z.string().nullable().optional(), + created_at: z.string().optional(), + modified_at: z.string().optional(), + permalink_url: z.string().optional(), +}) + +const asanaWorkspaceSchema = z.object({ + gid: z.string(), + name: z.string(), + resource_type: z.string().optional(), +}) + +const asanaListWorkspacesResponseSchema = z.object({ + success: z.literal(true), + ts: z.string(), + workspaces: z.array(asanaWorkspaceSchema), +}) + +const asanaDeleteTaskResponseSchema = z.object({ + success: z.literal(true), + ts: z.string(), + gid: z.string(), + deleted: z.literal(true), +}) + +const asanaAddFollowersResponseSchema = z.object({ + success: z.literal(true), + ts: z.string(), + gid: z.string(), + name: z.string(), + followers: z.array(asanaUserSummarySchema), +}) + +const asanaSectionSchema = z.object({ + gid: z.string(), + name: z.string(), + resource_type: z.string().optional(), +}) + +const asanaSectionResponseSchema = z.object({ + success: z.literal(true), + ts: z.string(), + gid: z.string(), + name: z.string(), + created_at: z.string().optional(), +}) + +const asanaListSectionsResponseSchema = z.object({ + success: z.literal(true), + ts: z.string(), + sections: z.array(asanaSectionSchema), +}) + export const asanaAddCommentBodySchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), taskGid: z.string().min(1, 'Task GID is required'), @@ -126,6 +186,53 @@ export const asanaUpdateTaskBodySchema = z.object({ due_on: z.string().nullish(), }) +export const asanaCreateProjectBodySchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + workspace: z.string().min(1, 'Workspace GID is required'), + name: z.string().min(1, 'Project name is required'), + notes: z.string().nullish(), +}) + +export const asanaGetProjectBodySchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + projectGid: z.string().min(1, 'Project GID is required'), +}) + +export const asanaListWorkspacesBodySchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), +}) + +export const asanaCreateSubtaskBodySchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + taskGid: z.string().min(1, 'Parent task GID is required'), + name: z.string().min(1, 'Subtask name is required'), + notes: z.string().nullish(), + assignee: z.string().nullish(), + due_on: z.string().nullish(), +}) + +export const asanaDeleteTaskBodySchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + taskGid: z.string().min(1, 'Task GID is required'), +}) + +export const asanaAddFollowersBodySchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + taskGid: z.string().min(1, 'Task GID is required'), + followers: z.array(z.string().min(1)).min(1, 'At least one follower GID is required'), +}) + +export const asanaCreateSectionBodySchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + projectGid: z.string().min(1, 'Project GID is required'), + name: z.string().min(1, 'Section name is required'), +}) + +export const asanaListSectionsBodySchema = z.object({ + accessToken: z.string().min(1, 'Access token is required'), + projectGid: z.string().min(1, 'Project GID is required'), +}) + export const asanaAddCommentContract = defineRouteContract({ method: 'POST', path: '/api/tools/asana/add-comment', @@ -168,6 +275,87 @@ export const asanaUpdateTaskContract = defineRouteContract({ response: { mode: 'json', schema: asanaTaskMutationResponseSchema }, }) +export const asanaCreateProjectContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/asana/create-project', + body: asanaCreateProjectBodySchema, + response: { mode: 'json', schema: asanaProjectRecordResponseSchema }, +}) + +export const asanaGetProjectContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/asana/get-project', + body: asanaGetProjectBodySchema, + response: { mode: 'json', schema: asanaProjectRecordResponseSchema }, +}) + +export const asanaListWorkspacesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/asana/list-workspaces', + body: asanaListWorkspacesBodySchema, + response: { mode: 'json', schema: asanaListWorkspacesResponseSchema }, +}) + +export const asanaCreateSubtaskContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/asana/create-subtask', + body: asanaCreateSubtaskBodySchema, + response: { mode: 'json', schema: asanaTaskMutationResponseSchema }, +}) + +export const asanaDeleteTaskContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/asana/delete-task', + body: asanaDeleteTaskBodySchema, + response: { mode: 'json', schema: asanaDeleteTaskResponseSchema }, +}) + +export const asanaAddFollowersContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/asana/add-followers', + body: asanaAddFollowersBodySchema, + response: { mode: 'json', schema: asanaAddFollowersResponseSchema }, +}) + +export const asanaCreateSectionContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/asana/create-section', + body: asanaCreateSectionBodySchema, + response: { mode: 'json', schema: asanaSectionResponseSchema }, +}) + +export const asanaListSectionsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/asana/list-sections', + body: asanaListSectionsBodySchema, + response: { mode: 'json', schema: asanaListSectionsResponseSchema }, +}) + +export type AsanaCreateProjectBody = ContractBody +export type AsanaCreateProjectBodyInput = ContractBodyInput +export type AsanaCreateProjectResponse = ContractJsonResponse +export type AsanaGetProjectBody = ContractBody +export type AsanaGetProjectBodyInput = ContractBodyInput +export type AsanaGetProjectResponse = ContractJsonResponse +export type AsanaListWorkspacesBody = ContractBody +export type AsanaListWorkspacesBodyInput = ContractBodyInput +export type AsanaListWorkspacesResponse = ContractJsonResponse +export type AsanaCreateSubtaskBody = ContractBody +export type AsanaCreateSubtaskBodyInput = ContractBodyInput +export type AsanaCreateSubtaskResponse = ContractJsonResponse +export type AsanaDeleteTaskBody = ContractBody +export type AsanaDeleteTaskBodyInput = ContractBodyInput +export type AsanaDeleteTaskResponse = ContractJsonResponse +export type AsanaAddFollowersBody = ContractBody +export type AsanaAddFollowersBodyInput = ContractBodyInput +export type AsanaAddFollowersResponse = ContractJsonResponse +export type AsanaCreateSectionBody = ContractBody +export type AsanaCreateSectionBodyInput = ContractBodyInput +export type AsanaCreateSectionResponse = ContractJsonResponse +export type AsanaListSectionsBody = ContractBody +export type AsanaListSectionsBodyInput = ContractBodyInput +export type AsanaListSectionsResponse = ContractJsonResponse + export type AsanaAddCommentBody = ContractBody export type AsanaAddCommentBodyInput = ContractBodyInput export type AsanaAddCommentResponse = ContractJsonResponse diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 4649bc1cad4..917a746c666 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-06-29", + "updatedAt": "2026-06-30", "integrations": [ { "type": "onepassword", @@ -949,9 +949,41 @@ { "name": "Add Comment", "description": "Add a comment (story) to an Asana task" + }, + { + "name": "Create Subtask", + "description": "Create a subtask under an existing Asana task" + }, + { + "name": "Delete Task", + "description": "Delete an Asana task by its GID (moves it to the trash)" + }, + { + "name": "Add Followers", + "description": "Add one or more followers to an Asana task" + }, + { + "name": "Create Project", + "description": "Create a new project in an Asana workspace" + }, + { + "name": "Get Project", + "description": "Retrieve a single Asana project by its GID" + }, + { + "name": "List Workspaces", + "description": "List all Asana workspaces and organizations the authenticated user belongs to" + }, + { + "name": "Create Section", + "description": "Create a new section in an Asana project" + }, + { + "name": "List Sections", + "description": "List all sections in an Asana project" } ], - "operationCount": 6, + "operationCount": 14, "triggers": [], "triggerCount": 0, "authType": "oauth", @@ -6383,9 +6415,33 @@ { "name": "Apply Text Style", "description": "Apply bold, italic, underline, and/or font size to a range of text in a Google Docs document, identified by its start and end character index." + }, + { + "name": "Apply Paragraph Style", + "description": "Apply a named paragraph style (such as a heading or title) and/or alignment to the paragraphs overlapping a range of text in a Google Docs document, identified by its start and end character index." + }, + { + "name": "Create Bullets", + "description": "Add bulleted or numbered list formatting to the paragraphs overlapping a range of text in a Google Docs document, using a chosen bullet glyph preset." + }, + { + "name": "Delete Bullets", + "description": "Remove bullet or numbered list formatting from the paragraphs overlapping a range of text in a Google Docs document, identified by its start and end character index." + }, + { + "name": "Delete Content Range", + "description": "Delete all content between a start and end character index in a Google Docs document. The endIndex is exclusive and must be greater than the startIndex." + }, + { + "name": "Create Named Range", + "description": "Create a named range over a span of content in a Google Docs document so it can be referenced or deleted later. The name may be 1-256 characters and need not be unique." + }, + { + "name": "Delete Named Range", + "description": "Delete one or more named ranges from a Google Docs document by their ID or by name. Provide exactly one of namedRangeId or name; deleting by name removes all ranges sharing that name. The content itself is not removed." } ], - "operationCount": 9, + "operationCount": 15, "triggers": [], "triggerCount": 0, "authType": "oauth", @@ -8729,9 +8785,29 @@ { "name": "Search Users", "description": "Search for Jira users by email address or display name. Returns matching users with their accountId, displayName, and emailAddress." + }, + { + "name": "List Projects", + "description": "List Jira projects visible to the user, with optional name/key filtering and pagination. Returns each project with id, key, name, and type." + }, + { + "name": "Get Project", + "description": "Get the details of a single Jira project by its ID or key, including its type, lead, components, issue types, and versions." + }, + { + "name": "Get Transitions", + "description": "Get the workflow transitions available for an issue in its current status. Use the returned transition IDs with the Transition Issue operation." + }, + { + "name": "List Issue Types", + "description": "List all issue types visible to the user across projects (e.g., Task, Bug, Story, Epic, Subtask). Useful for discovering valid issue types before creating an issue." + }, + { + "name": "Get Fields", + "description": "Get all system and custom fields defined in the Jira instance. Useful for discovering custom field IDs (e.g., customfield_10001) to use when writing or updating issues." } ], - "operationCount": 25, + "operationCount": 30, "triggers": [ { "id": "jira_issue_created", @@ -10979,6 +11055,14 @@ "name": "Update Item", "description": "Update column values of an item on a Monday.com board" }, + { + "name": "Change Column Value", + "description": "Update a single column's value on a Monday.com item" + }, + { + "name": "Duplicate Item", + "description": "Duplicate an existing item on a Monday.com board" + }, { "name": "Delete Item", "description": "Delete an item from a Monday.com board" @@ -11002,9 +11086,21 @@ { "name": "Create Group", "description": "Create a new group on a Monday.com board" + }, + { + "name": "Get Groups", + "description": "Get the groups on a Monday.com board" + }, + { + "name": "Create Board", + "description": "Create a new board in Monday.com" + }, + { + "name": "Create Column", + "description": "Create a new column on a Monday.com board" } ], - "operationCount": 13, + "operationCount": 18, "triggers": [ { "id": "monday_item_created", @@ -15506,9 +15602,37 @@ { "name": "Publish View", "description": "Publish a static view to a user's Home tab in Slack. Used to create or update the app's Home tab experience." + }, + { + "name": "Schedule Message", + "description": "Schedule a message to be sent to a Slack channel or DM at a future time." + }, + { + "name": "List Scheduled Messages", + "description": "List pending scheduled messages in a Slack workspace, optionally filtered by channel." + }, + { + "name": "Delete Scheduled Message", + "description": "Delete a pending scheduled message before it posts to Slack." + }, + { + "name": "Archive Conversation", + "description": "Archive a Slack channel so it is closed to new activity." + }, + { + "name": "Rename Conversation", + "description": "Rename an existing Slack channel." + }, + { + "name": "Set Conversation Topic", + "description": "Set the topic for a Slack channel (max 250 characters)." + }, + { + "name": "Set Conversation Purpose", + "description": "Set the purpose (description) for a Slack channel (max 250 characters)." } ], - "operationCount": 35, + "operationCount": 42, "triggers": [ { "id": "slack_webhook", @@ -17666,6 +17790,10 @@ "name": "Create Card", "description": "Create a new card in a Trello list" }, + { + "name": "Get Card", + "description": "Retrieve a single Trello card by ID" + }, { "name": "Update Card", "description": "Update an existing card on Trello" @@ -17677,9 +17805,33 @@ { "name": "Add Comment", "description": "Add a comment to a Trello card" + }, + { + "name": "Add Checklist", + "description": "Add a checklist to a Trello card" + }, + { + "name": "Add Label", + "description": "Attach an existing label to a Trello card" + }, + { + "name": "Add Member", + "description": "Assign a member to a Trello card" + }, + { + "name": "Create Board", + "description": "Create a new Trello board" + }, + { + "name": "Get Board", + "description": "Retrieve a single Trello board by ID" + }, + { + "name": "Create List", + "description": "Create a new list on a Trello board" } ], - "operationCount": 6, + "operationCount": 13, "triggers": [], "triggerCount": 0, "authType": "oauth", diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index 28276bc28fd..17ff65e51fb 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -277,8 +277,10 @@ export const SCOPE_DESCRIPTIONS: Record = { // Slack scopes 'channels:read': 'View public channels', 'channels:history': 'Read channel messages', + 'channels:manage': 'Create, archive, and rename public channels', 'groups:read': 'View private channels', 'groups:history': 'Read private messages', + 'groups:write': 'Create, archive, and manage private channels', 'chat:write': 'Send messages', 'chat:write.public': 'Post to public channels', 'assistant:write': 'Set assistant thread status, title, and suggested prompts', @@ -292,6 +294,7 @@ export const SCOPE_DESCRIPTIONS: Record = { 'canvases:read': 'Read canvas sections', 'canvases:write': 'Create, edit, and delete canvas documents', 'reactions:write': 'Add emoji reactions to messages', + 'reactions:read': 'View emoji reactions on messages', // Webflow scopes 'sites:read': 'View Webflow sites', diff --git a/apps/sim/tools/asana/add_followers.ts b/apps/sim/tools/asana/add_followers.ts new file mode 100644 index 00000000000..4c3c8c358cc --- /dev/null +++ b/apps/sim/tools/asana/add_followers.ts @@ -0,0 +1,87 @@ +import type { AsanaAddFollowersParams, AsanaAddFollowersResponse } from '@/tools/asana/types' +import type { ToolConfig } from '@/tools/types' + +export const asanaAddFollowersTool: ToolConfig = + { + id: 'asana_add_followers', + name: 'Asana Add Followers', + description: 'Add one or more followers to an Asana task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'asana', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Asana', + }, + taskGid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'GID of the Asana task (numeric string)', + }, + followers: { + type: 'array', + required: true, + visibility: 'user-or-llm', + description: 'Array of user GIDs to add as followers to the task', + }, + }, + + request: { + url: '/api/tools/asana/add-followers', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + taskGid: params.taskGid, + followers: params.followers, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), gid: '', name: '', followers: [] }, + error: 'Empty response from Asana', + } + } + + const data = JSON.parse(responseText) + const { success, error, ...output } = data + return { + success: success ?? true, + output, + error, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Operation success status' }, + ts: { type: 'string', description: 'Timestamp of the response' }, + gid: { type: 'string', description: 'Task globally unique identifier' }, + name: { type: 'string', description: 'Task name' }, + followers: { + type: 'array', + description: 'Current followers on the task after the update', + items: { + type: 'object', + properties: { + gid: { type: 'string', description: 'Follower GID' }, + name: { type: 'string', description: 'Follower name' }, + }, + }, + }, + }, + } diff --git a/apps/sim/tools/asana/create_project.ts b/apps/sim/tools/asana/create_project.ts new file mode 100644 index 00000000000..b79bcefb4f6 --- /dev/null +++ b/apps/sim/tools/asana/create_project.ts @@ -0,0 +1,91 @@ +import type { AsanaCreateProjectParams, AsanaProjectRecordResponse } from '@/tools/asana/types' +import type { ToolConfig } from '@/tools/types' + +export const asanaCreateProjectTool: ToolConfig< + AsanaCreateProjectParams, + AsanaProjectRecordResponse +> = { + id: 'asana_create_project', + name: 'Asana Create Project', + description: 'Create a new project in an Asana workspace', + version: '1.0.0', + + oauth: { + required: true, + provider: 'asana', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Asana', + }, + workspace: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Asana workspace GID (numeric string) where the project will be created', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the project', + }, + notes: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Notes or description for the project', + }, + }, + + request: { + url: '/api/tools/asana/create-project', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + workspace: params.workspace, + name: params.name, + notes: params.notes, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), gid: '', name: '', notes: '' }, + error: 'Empty response from Asana', + } + } + + const data = JSON.parse(responseText) + const { success, error, ...output } = data + return { + success: success ?? true, + output, + error, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Operation success status' }, + ts: { type: 'string', description: 'Timestamp of the response' }, + gid: { type: 'string', description: 'Project globally unique identifier' }, + name: { type: 'string', description: 'Project name' }, + notes: { type: 'string', description: 'Project notes or description' }, + archived: { type: 'boolean', description: 'Whether the project is archived' }, + color: { type: 'string', description: 'Project color' }, + created_at: { type: 'string', description: 'Project creation timestamp' }, + modified_at: { type: 'string', description: 'Project last modified timestamp' }, + permalink_url: { type: 'string', description: 'URL to the project in Asana' }, + }, +} diff --git a/apps/sim/tools/asana/create_section.ts b/apps/sim/tools/asana/create_section.ts new file mode 100644 index 00000000000..90a40da32c4 --- /dev/null +++ b/apps/sim/tools/asana/create_section.ts @@ -0,0 +1,76 @@ +import type { AsanaCreateSectionParams, AsanaSectionResponse } from '@/tools/asana/types' +import type { ToolConfig } from '@/tools/types' + +export const asanaCreateSectionTool: ToolConfig = { + id: 'asana_create_section', + name: 'Asana Create Section', + description: 'Create a new section in an Asana project', + version: '1.0.0', + + oauth: { + required: true, + provider: 'asana', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Asana', + }, + projectGid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'GID of the Asana project (numeric string) to add the section to', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the section', + }, + }, + + request: { + url: '/api/tools/asana/create-section', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + projectGid: params.projectGid, + name: params.name, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), gid: '', name: '' }, + error: 'Empty response from Asana', + } + } + + const data = JSON.parse(responseText) + const { success, error, ...output } = data + return { + success: success ?? true, + output, + error, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Operation success status' }, + ts: { type: 'string', description: 'Timestamp of the response' }, + gid: { type: 'string', description: 'Section globally unique identifier' }, + name: { type: 'string', description: 'Section name' }, + created_at: { type: 'string', description: 'Section creation timestamp' }, + }, +} diff --git a/apps/sim/tools/asana/create_subtask.ts b/apps/sim/tools/asana/create_subtask.ts new file mode 100644 index 00000000000..1e653b9871f --- /dev/null +++ b/apps/sim/tools/asana/create_subtask.ts @@ -0,0 +1,109 @@ +import type { AsanaCreateSubtaskParams, AsanaCreateTaskResponse } from '@/tools/asana/types' +import type { ToolConfig } from '@/tools/types' + +export const asanaCreateSubtaskTool: ToolConfig = + { + id: 'asana_create_subtask', + name: 'Asana Create Subtask', + description: 'Create a subtask under an existing Asana task', + version: '1.0.0', + + oauth: { + required: true, + provider: 'asana', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Asana', + }, + taskGid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'GID of the parent Asana task (numeric string)', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the subtask', + }, + notes: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Notes or description for the subtask', + }, + assignee: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'User GID to assign the subtask to', + }, + due_on: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Due date in YYYY-MM-DD format', + }, + }, + + request: { + url: '/api/tools/asana/create-subtask', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + taskGid: params.taskGid, + name: params.name, + notes: params.notes, + assignee: params.assignee, + due_on: params.due_on, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { + ts: new Date().toISOString(), + gid: '', + name: '', + notes: '', + completed: false, + created_at: new Date().toISOString(), + permalink_url: '', + }, + error: 'Empty response from Asana', + } + } + + const data = JSON.parse(responseText) + const { success, error, ...output } = data + return { + success: success ?? true, + output, + error, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Operation success status' }, + ts: { type: 'string', description: 'Timestamp of the response' }, + gid: { type: 'string', description: 'Subtask globally unique identifier' }, + name: { type: 'string', description: 'Subtask name' }, + notes: { type: 'string', description: 'Subtask notes or description' }, + completed: { type: 'boolean', description: 'Whether the subtask is completed' }, + created_at: { type: 'string', description: 'Subtask creation timestamp' }, + permalink_url: { type: 'string', description: 'URL to the subtask in Asana' }, + }, + } diff --git a/apps/sim/tools/asana/delete_task.ts b/apps/sim/tools/asana/delete_task.ts new file mode 100644 index 00000000000..337238c8d45 --- /dev/null +++ b/apps/sim/tools/asana/delete_task.ts @@ -0,0 +1,68 @@ +import type { AsanaDeleteTaskParams, AsanaDeleteTaskResponse } from '@/tools/asana/types' +import type { ToolConfig } from '@/tools/types' + +export const asanaDeleteTaskTool: ToolConfig = { + id: 'asana_delete_task', + name: 'Asana Delete Task', + description: 'Delete an Asana task by its GID (moves it to the trash)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'asana', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Asana', + }, + taskGid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'GID of the Asana task to delete (numeric string)', + }, + }, + + request: { + url: '/api/tools/asana/delete-task', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + taskGid: params.taskGid, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), gid: '', deleted: true }, + error: 'Empty response from Asana', + } + } + + const data = JSON.parse(responseText) + const { success, error, ...output } = data + return { + success: success ?? true, + output, + error, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Operation success status' }, + ts: { type: 'string', description: 'Timestamp of the response' }, + gid: { type: 'string', description: 'GID of the deleted task' }, + deleted: { type: 'boolean', description: 'Whether the task was deleted' }, + }, +} diff --git a/apps/sim/tools/asana/get_project.ts b/apps/sim/tools/asana/get_project.ts new file mode 100644 index 00000000000..661aeda6df9 --- /dev/null +++ b/apps/sim/tools/asana/get_project.ts @@ -0,0 +1,74 @@ +import type { AsanaGetProjectParams, AsanaProjectRecordResponse } from '@/tools/asana/types' +import type { ToolConfig } from '@/tools/types' + +export const asanaGetProjectTool: ToolConfig = { + id: 'asana_get_project', + name: 'Asana Get Project', + description: 'Retrieve a single Asana project by its GID', + version: '1.0.0', + + oauth: { + required: true, + provider: 'asana', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Asana', + }, + projectGid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Asana project GID (numeric string) to retrieve', + }, + }, + + request: { + url: '/api/tools/asana/get-project', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + projectGid: params.projectGid, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), gid: '', name: '', notes: '' }, + error: 'Empty response from Asana', + } + } + + const data = JSON.parse(responseText) + const { success, error, ...output } = data + return { + success: success ?? true, + output, + error, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Operation success status' }, + ts: { type: 'string', description: 'Timestamp of the response' }, + gid: { type: 'string', description: 'Project globally unique identifier' }, + name: { type: 'string', description: 'Project name' }, + notes: { type: 'string', description: 'Project notes or description' }, + archived: { type: 'boolean', description: 'Whether the project is archived' }, + color: { type: 'string', description: 'Project color' }, + created_at: { type: 'string', description: 'Project creation timestamp' }, + modified_at: { type: 'string', description: 'Project last modified timestamp' }, + permalink_url: { type: 'string', description: 'URL to the project in Asana' }, + }, +} diff --git a/apps/sim/tools/asana/index.ts b/apps/sim/tools/asana/index.ts index 5099c422aa0..ae3dca1c1c2 100644 --- a/apps/sim/tools/asana/index.ts +++ b/apps/sim/tools/asana/index.ts @@ -1,7 +1,15 @@ import { asanaAddCommentTool } from '@/tools/asana/add_comment' +import { asanaAddFollowersTool } from '@/tools/asana/add_followers' +import { asanaCreateProjectTool } from '@/tools/asana/create_project' +import { asanaCreateSectionTool } from '@/tools/asana/create_section' +import { asanaCreateSubtaskTool } from '@/tools/asana/create_subtask' import { asanaCreateTaskTool } from '@/tools/asana/create_task' +import { asanaDeleteTaskTool } from '@/tools/asana/delete_task' +import { asanaGetProjectTool } from '@/tools/asana/get_project' import { asanaGetProjectsTool } from '@/tools/asana/get_projects' import { asanaGetTaskTool } from '@/tools/asana/get_task' +import { asanaListSectionsTool } from '@/tools/asana/list_sections' +import { asanaListWorkspacesTool } from '@/tools/asana/list_workspaces' import { asanaSearchTasksTool } from '@/tools/asana/search_tasks' import { asanaUpdateTaskTool } from '@/tools/asana/update_task' @@ -11,3 +19,11 @@ export { asanaUpdateTaskTool } export { asanaGetProjectsTool } export { asanaSearchTasksTool } export { asanaAddCommentTool } +export { asanaCreateProjectTool } +export { asanaGetProjectTool } +export { asanaListWorkspacesTool } +export { asanaCreateSubtaskTool } +export { asanaDeleteTaskTool } +export { asanaAddFollowersTool } +export { asanaCreateSectionTool } +export { asanaListSectionsTool } diff --git a/apps/sim/tools/asana/list_sections.ts b/apps/sim/tools/asana/list_sections.ts new file mode 100644 index 00000000000..a0fa47e2fe5 --- /dev/null +++ b/apps/sim/tools/asana/list_sections.ts @@ -0,0 +1,79 @@ +import type { AsanaListSectionsParams, AsanaListSectionsResponse } from '@/tools/asana/types' +import type { ToolConfig } from '@/tools/types' + +export const asanaListSectionsTool: ToolConfig = + { + id: 'asana_list_sections', + name: 'Asana List Sections', + description: 'List all sections in an Asana project', + version: '1.0.0', + + oauth: { + required: true, + provider: 'asana', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Asana', + }, + projectGid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'GID of the Asana project (numeric string) to list sections from', + }, + }, + + request: { + url: '/api/tools/asana/list-sections', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + projectGid: params.projectGid, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), sections: [] }, + error: 'Empty response from Asana', + } + } + + const data = JSON.parse(responseText) + const { success, error, ...output } = data + return { + success: success ?? true, + output, + error, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Operation success status' }, + ts: { type: 'string', description: 'Timestamp of the response' }, + sections: { + type: 'array', + description: 'Array of sections in the project', + items: { + type: 'object', + properties: { + gid: { type: 'string', description: 'Section GID' }, + name: { type: 'string', description: 'Section name' }, + resource_type: { type: 'string', description: 'Resource type (section)' }, + }, + }, + }, + }, + } diff --git a/apps/sim/tools/asana/list_workspaces.ts b/apps/sim/tools/asana/list_workspaces.ts new file mode 100644 index 00000000000..ff182f15063 --- /dev/null +++ b/apps/sim/tools/asana/list_workspaces.ts @@ -0,0 +1,74 @@ +import type { AsanaListWorkspacesParams, AsanaListWorkspacesResponse } from '@/tools/asana/types' +import type { ToolConfig } from '@/tools/types' + +export const asanaListWorkspacesTool: ToolConfig< + AsanaListWorkspacesParams, + AsanaListWorkspacesResponse +> = { + id: 'asana_list_workspaces', + name: 'Asana List Workspaces', + description: 'List all Asana workspaces and organizations the authenticated user belongs to', + version: '1.0.0', + + oauth: { + required: true, + provider: 'asana', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Asana', + }, + }, + + request: { + url: '/api/tools/asana/list-workspaces', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + }), + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + + if (!responseText) { + return { + success: false, + output: { ts: new Date().toISOString(), workspaces: [] }, + error: 'Empty response from Asana', + } + } + + const data = JSON.parse(responseText) + const { success, error, ...output } = data + return { + success: success ?? true, + output, + error, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Operation success status' }, + ts: { type: 'string', description: 'Timestamp of the response' }, + workspaces: { + type: 'array', + description: 'Array of workspaces', + items: { + type: 'object', + properties: { + gid: { type: 'string', description: 'Workspace GID' }, + name: { type: 'string', description: 'Workspace name' }, + resource_type: { type: 'string', description: 'Resource type (workspace)' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/asana/types.ts b/apps/sim/tools/asana/types.ts index c728b86bd1c..dfa688c7570 100644 --- a/apps/sim/tools/asana/types.ts +++ b/apps/sim/tools/asana/types.ts @@ -201,6 +201,118 @@ export interface AsanaAddCommentResponse extends ToolResponse { } } +export interface AsanaCreateProjectParams { + accessToken: string + workspace: string + name: string + notes?: string +} + +export interface AsanaProjectRecordResponse extends ToolResponse { + output: { + ts: string + gid: string + name: string + notes: string + archived?: boolean + color?: string | null + created_at?: string + modified_at?: string + permalink_url?: string + } +} + +export interface AsanaGetProjectParams { + accessToken: string + projectGid: string +} + +export interface AsanaListWorkspacesParams { + accessToken: string +} + +export interface AsanaListWorkspacesResponse extends ToolResponse { + output: { + ts: string + workspaces: Array<{ + gid: string + name: string + resource_type?: string + }> + } +} + +export interface AsanaCreateSubtaskParams { + accessToken: string + taskGid: string + name: string + notes?: string + assignee?: string + due_on?: string +} + +export interface AsanaDeleteTaskParams { + accessToken: string + taskGid: string +} + +export interface AsanaDeleteTaskResponse extends ToolResponse { + output: { + ts: string + gid: string + deleted: true + } +} + +export interface AsanaAddFollowersParams { + accessToken: string + taskGid: string + followers: string[] +} + +export interface AsanaAddFollowersResponse extends ToolResponse { + output: { + ts: string + gid: string + name: string + followers: Array<{ + gid: string + name: string + }> + } +} + +export interface AsanaCreateSectionParams { + accessToken: string + projectGid: string + name: string +} + +export interface AsanaSectionResponse extends ToolResponse { + output: { + ts: string + gid: string + name: string + created_at?: string + } +} + +export interface AsanaListSectionsParams { + accessToken: string + projectGid: string +} + +export interface AsanaListSectionsResponse extends ToolResponse { + output: { + ts: string + sections: Array<{ + gid: string + name: string + resource_type?: string + }> + } +} + export type AsanaResponse = | AsanaGetTaskResponse | AsanaCreateTaskResponse @@ -208,3 +320,9 @@ export type AsanaResponse = | AsanaGetProjectsResponse | AsanaSearchTasksResponse | AsanaAddCommentResponse + | AsanaProjectRecordResponse + | AsanaListWorkspacesResponse + | AsanaDeleteTaskResponse + | AsanaAddFollowersResponse + | AsanaSectionResponse + | AsanaListSectionsResponse diff --git a/apps/sim/tools/context_dev/map.ts b/apps/sim/tools/context_dev/map.ts index c8491b02d5e..823d2de501b 100644 --- a/apps/sim/tools/context_dev/map.ts +++ b/apps/sim/tools/context_dev/map.ts @@ -83,7 +83,8 @@ export const contextDevMapTool: ToolConfig = { + id: 'google_docs_create_named_range', + name: 'Create Named Range in Google Docs Document', + description: + 'Create a named range over a span of content in a Google Docs document so it can be referenced or deleted later. The name may be 1-256 characters and need not be unique.', + version: '1.0', + oauth: { + required: true, + provider: 'google-docs', + }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Google Docs API', + }, + documentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the document to update', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the range to create (1-256 characters)', + }, + startIndex: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'The start character index (the document body starts at index 1) of the range (inclusive)', + }, + endIndex: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The end character index of the range (exclusive)', + }, + }, + request: { + url: (params) => { + const documentId = resolveDocumentId(params) + return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate` + }, + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + const name = params.name ? String(params.name).trim() : '' + if (!name) { + throw new Error('name is required') + } + if (name.length > 256) { + throw new Error('name must be 256 characters or fewer') + } + const range = buildContentRange(params.startIndex, params.endIndex) + return { + requests: [ + { + createNamedRange: { name, range }, + }, + ], + } + }, + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + const data = responseText.trim() ? JSON.parse(responseText) : {} + const metadata = buildBatchUpdateMetadata(data, response.url) + const namedRangeId = data.replies?.[0]?.createNamedRange?.namedRangeId ?? null + + return { + success: true, + output: { + namedRangeId, + metadata, + }, + } + }, + + outputs: { + namedRangeId: { + type: 'string', + description: 'The ID of the created named range', + optional: true, + }, + metadata: { + type: 'json', + description: 'Updated document metadata including ID, title, and URL', + properties: { + documentId: { type: 'string', description: 'Google Docs document ID' }, + title: { type: 'string', description: 'Document title' }, + mimeType: { type: 'string', description: 'Document MIME type' }, + url: { type: 'string', description: 'Document URL' }, + }, + }, + }, +} diff --git a/apps/sim/tools/google_docs/create-paragraph-bullets.ts b/apps/sim/tools/google_docs/create-paragraph-bullets.ts new file mode 100644 index 00000000000..7ba6651dcc7 --- /dev/null +++ b/apps/sim/tools/google_docs/create-paragraph-bullets.ts @@ -0,0 +1,144 @@ +import type { + GoogleDocsCreateParagraphBulletsResponse, + GoogleDocsToolParams, +} from '@/tools/google_docs/types' +import { + buildBatchUpdateMetadata, + buildContentRange, + resolveDocumentId, +} from '@/tools/google_docs/utils' +import type { ToolConfig } from '@/tools/types' + +const BULLET_PRESETS = new Set([ + 'BULLET_DISC_CIRCLE_SQUARE', + 'BULLET_DIAMONDX_ARROW3D_SQUARE', + 'BULLET_CHECKBOX', + 'BULLET_ARROW_DIAMOND_DISC', + 'BULLET_STAR_CIRCLE_SQUARE', + 'BULLET_ARROW3D_CIRCLE_SQUARE', + 'NUMBERED_DECIMAL_ALPHA_ROMAN', + 'NUMBERED_DECIMAL_ALPHA_ROMAN_PARENS', + 'NUMBERED_DECIMAL_NESTED', + 'NUMBERED_UPPERALPHA_ALPHA_ROMAN', + 'NUMBERED_UPPERROMAN_UPPERALPHA_DECIMAL', + 'NUMBERED_ZERODECIMAL_ALPHA_ROMAN', +]) + +const DEFAULT_BULLET_PRESET = 'BULLET_DISC_CIRCLE_SQUARE' + +export const createParagraphBulletsTool: ToolConfig< + GoogleDocsToolParams, + GoogleDocsCreateParagraphBulletsResponse +> = { + id: 'google_docs_create_paragraph_bullets', + name: 'Create Paragraph Bullets in Google Docs Document', + description: + 'Add bulleted or numbered list formatting to the paragraphs overlapping a range of text in a Google Docs document, using a chosen bullet glyph preset.', + version: '1.0', + oauth: { + required: true, + provider: 'google-docs', + }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Google Docs API', + }, + documentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the document to update', + }, + startIndex: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'The start character index (the document body starts at index 1) of the range to bullet (inclusive)', + }, + endIndex: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The end character index of the range to bullet (exclusive)', + }, + bulletPreset: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The bullet glyph preset to apply. Defaults to BULLET_DISC_CIRCLE_SQUARE. Examples: BULLET_DISC_CIRCLE_SQUARE, BULLET_CHECKBOX, NUMBERED_DECIMAL_ALPHA_ROMAN, NUMBERED_DECIMAL_NESTED.', + }, + }, + request: { + url: (params) => { + const documentId = resolveDocumentId(params) + return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate` + }, + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + const range = buildContentRange(params.startIndex, params.endIndex) + + let bulletPreset = DEFAULT_BULLET_PRESET + if (params.bulletPreset != null && String(params.bulletPreset).trim() !== '') { + bulletPreset = String(params.bulletPreset).trim().toUpperCase() + if (!BULLET_PRESETS.has(bulletPreset)) { + throw new Error( + 'bulletPreset must be a valid BulletGlyphPreset (e.g. BULLET_DISC_CIRCLE_SQUARE, BULLET_CHECKBOX, NUMBERED_DECIMAL_ALPHA_ROMAN)' + ) + } + } + + return { + requests: [ + { + createParagraphBullets: { range, bulletPreset }, + }, + ], + } + }, + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + const data = responseText.trim() ? JSON.parse(responseText) : {} + const metadata = buildBatchUpdateMetadata(data, response.url) + + return { + success: true, + output: { + updatedContent: true, + metadata, + }, + } + }, + + outputs: { + updatedContent: { + type: 'boolean', + description: 'Indicates if the bullets were applied successfully', + }, + metadata: { + type: 'json', + description: 'Updated document metadata including ID, title, and URL', + properties: { + documentId: { type: 'string', description: 'Google Docs document ID' }, + title: { type: 'string', description: 'Document title' }, + mimeType: { type: 'string', description: 'Document MIME type' }, + url: { type: 'string', description: 'Document URL' }, + }, + }, + }, +} diff --git a/apps/sim/tools/google_docs/delete-content-range.ts b/apps/sim/tools/google_docs/delete-content-range.ts new file mode 100644 index 00000000000..20f226f388d --- /dev/null +++ b/apps/sim/tools/google_docs/delete-content-range.ts @@ -0,0 +1,109 @@ +import type { + GoogleDocsDeleteContentRangeResponse, + GoogleDocsToolParams, +} from '@/tools/google_docs/types' +import { + buildBatchUpdateMetadata, + buildContentRange, + resolveDocumentId, +} from '@/tools/google_docs/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteContentRangeTool: ToolConfig< + GoogleDocsToolParams, + GoogleDocsDeleteContentRangeResponse +> = { + id: 'google_docs_delete_content_range', + name: 'Delete Content Range in Google Docs Document', + description: + 'Delete all content between a start and end character index in a Google Docs document. The endIndex is exclusive and must be greater than the startIndex.', + version: '1.0', + oauth: { + required: true, + provider: 'google-docs', + }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Google Docs API', + }, + documentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the document to delete content from', + }, + startIndex: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'The start character index (the document body starts at index 1) of the range to delete (inclusive)', + }, + endIndex: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The end character index of the range to delete (exclusive)', + }, + }, + request: { + url: (params) => { + const documentId = resolveDocumentId(params) + return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate` + }, + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + const range = buildContentRange(params.startIndex, params.endIndex) + return { + requests: [ + { + deleteContentRange: { range }, + }, + ], + } + }, + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + const data = responseText.trim() ? JSON.parse(responseText) : {} + const metadata = buildBatchUpdateMetadata(data, response.url) + + return { + success: true, + output: { + updatedContent: true, + metadata, + }, + } + }, + + outputs: { + updatedContent: { + type: 'boolean', + description: 'Indicates if the content range was deleted successfully', + }, + metadata: { + type: 'json', + description: 'Updated document metadata including ID, title, and URL', + properties: { + documentId: { type: 'string', description: 'Google Docs document ID' }, + title: { type: 'string', description: 'Document title' }, + mimeType: { type: 'string', description: 'Document MIME type' }, + url: { type: 'string', description: 'Document URL' }, + }, + }, + }, +} diff --git a/apps/sim/tools/google_docs/delete-named-range.ts b/apps/sim/tools/google_docs/delete-named-range.ts new file mode 100644 index 00000000000..3a2ce788035 --- /dev/null +++ b/apps/sim/tools/google_docs/delete-named-range.ts @@ -0,0 +1,110 @@ +import type { + GoogleDocsDeleteNamedRangeResponse, + GoogleDocsToolParams, +} from '@/tools/google_docs/types' +import { buildBatchUpdateMetadata, resolveDocumentId } from '@/tools/google_docs/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteNamedRangeTool: ToolConfig< + GoogleDocsToolParams, + GoogleDocsDeleteNamedRangeResponse +> = { + id: 'google_docs_delete_named_range', + name: 'Delete Named Range in Google Docs Document', + description: + 'Delete one or more named ranges from a Google Docs document by their ID or by name. Provide exactly one of namedRangeId or name; deleting by name removes all ranges sharing that name. The content itself is not removed.', + version: '1.0', + oauth: { + required: true, + provider: 'google-docs', + }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Google Docs API', + }, + documentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the document to update', + }, + namedRangeId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The ID of the named range to delete. Provide exactly one of namedRangeId or namedRangeName.', + }, + namedRangeName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The name of the named range(s) to delete. All ranges sharing this name are removed. Provide exactly one of namedRangeId or namedRangeName.', + }, + }, + request: { + url: (params) => { + const documentId = resolveDocumentId(params) + return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate` + }, + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + const namedRangeId = params.namedRangeId ? String(params.namedRangeId).trim() : '' + const name = params.namedRangeName ? String(params.namedRangeName).trim() : '' + if (!namedRangeId && !name) { + throw new Error('Either namedRangeId or namedRangeName is required') + } + if (namedRangeId && name) { + throw new Error('Provide exactly one of namedRangeId or namedRangeName, not both') + } + const deleteNamedRange = namedRangeId ? { namedRangeId } : { name } + return { + requests: [{ deleteNamedRange }], + } + }, + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + const data = responseText.trim() ? JSON.parse(responseText) : {} + const metadata = buildBatchUpdateMetadata(data, response.url) + + return { + success: true, + output: { + updatedContent: true, + metadata, + }, + } + }, + + outputs: { + updatedContent: { + type: 'boolean', + description: 'Indicates if the named range(s) were deleted successfully', + }, + metadata: { + type: 'json', + description: 'Updated document metadata including ID, title, and URL', + properties: { + documentId: { type: 'string', description: 'Google Docs document ID' }, + title: { type: 'string', description: 'Document title' }, + mimeType: { type: 'string', description: 'Document MIME type' }, + url: { type: 'string', description: 'Document URL' }, + }, + }, + }, +} diff --git a/apps/sim/tools/google_docs/delete-paragraph-bullets.ts b/apps/sim/tools/google_docs/delete-paragraph-bullets.ts new file mode 100644 index 00000000000..67d2d54fa5f --- /dev/null +++ b/apps/sim/tools/google_docs/delete-paragraph-bullets.ts @@ -0,0 +1,109 @@ +import type { + GoogleDocsDeleteParagraphBulletsResponse, + GoogleDocsToolParams, +} from '@/tools/google_docs/types' +import { + buildBatchUpdateMetadata, + buildContentRange, + resolveDocumentId, +} from '@/tools/google_docs/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteParagraphBulletsTool: ToolConfig< + GoogleDocsToolParams, + GoogleDocsDeleteParagraphBulletsResponse +> = { + id: 'google_docs_delete_paragraph_bullets', + name: 'Delete Paragraph Bullets in Google Docs Document', + description: + 'Remove bullet or numbered list formatting from the paragraphs overlapping a range of text in a Google Docs document, identified by its start and end character index.', + version: '1.0', + oauth: { + required: true, + provider: 'google-docs', + }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Google Docs API', + }, + documentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the document to update', + }, + startIndex: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'The start character index (the document body starts at index 1) of the range to clear bullets from (inclusive)', + }, + endIndex: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The end character index of the range to clear bullets from (exclusive)', + }, + }, + request: { + url: (params) => { + const documentId = resolveDocumentId(params) + return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate` + }, + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + const range = buildContentRange(params.startIndex, params.endIndex) + return { + requests: [ + { + deleteParagraphBullets: { range }, + }, + ], + } + }, + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + const data = responseText.trim() ? JSON.parse(responseText) : {} + const metadata = buildBatchUpdateMetadata(data, response.url) + + return { + success: true, + output: { + updatedContent: true, + metadata, + }, + } + }, + + outputs: { + updatedContent: { + type: 'boolean', + description: 'Indicates if the bullets were removed successfully', + }, + metadata: { + type: 'json', + description: 'Updated document metadata including ID, title, and URL', + properties: { + documentId: { type: 'string', description: 'Google Docs document ID' }, + title: { type: 'string', description: 'Document title' }, + mimeType: { type: 'string', description: 'Document MIME type' }, + url: { type: 'string', description: 'Document URL' }, + }, + }, + }, +} diff --git a/apps/sim/tools/google_docs/index.ts b/apps/sim/tools/google_docs/index.ts index f69d6de914a..33a4cf1bc11 100644 --- a/apps/sim/tools/google_docs/index.ts +++ b/apps/sim/tools/google_docs/index.ts @@ -1,10 +1,16 @@ import { createTool } from '@/tools/google_docs/create' +import { createNamedRangeTool } from '@/tools/google_docs/create-named-range' +import { createParagraphBulletsTool } from '@/tools/google_docs/create-paragraph-bullets' +import { deleteContentRangeTool } from '@/tools/google_docs/delete-content-range' +import { deleteNamedRangeTool } from '@/tools/google_docs/delete-named-range' +import { deleteParagraphBulletsTool } from '@/tools/google_docs/delete-paragraph-bullets' import { insertImageTool } from '@/tools/google_docs/insert-image' import { insertPageBreakTool } from '@/tools/google_docs/insert-page-break' import { insertTableTool } from '@/tools/google_docs/insert-table' import { insertTextTool } from '@/tools/google_docs/insert-text' import { readTool } from '@/tools/google_docs/read' import { replaceTextTool } from '@/tools/google_docs/replace-text' +import { updateParagraphStyleTool } from '@/tools/google_docs/update-paragraph-style' import { updateTextStyleTool } from '@/tools/google_docs/update-text-style' import { writeTool } from '@/tools/google_docs/write' @@ -17,5 +23,11 @@ export const googleDocsInsertTableTool = insertTableTool export const googleDocsInsertImageTool = insertImageTool export const googleDocsInsertPageBreakTool = insertPageBreakTool export const googleDocsUpdateTextStyleTool = updateTextStyleTool +export const googleDocsDeleteContentRangeTool = deleteContentRangeTool +export const googleDocsUpdateParagraphStyleTool = updateParagraphStyleTool +export const googleDocsCreateParagraphBulletsTool = createParagraphBulletsTool +export const googleDocsDeleteParagraphBulletsTool = deleteParagraphBulletsTool +export const googleDocsCreateNamedRangeTool = createNamedRangeTool +export const googleDocsDeleteNamedRangeTool = deleteNamedRangeTool export * from './types' diff --git a/apps/sim/tools/google_docs/insert-image.ts b/apps/sim/tools/google_docs/insert-image.ts index 82818e49e90..13b37d0057a 100644 --- a/apps/sim/tools/google_docs/insert-image.ts +++ b/apps/sim/tools/google_docs/insert-image.ts @@ -40,7 +40,7 @@ export const insertImageTool: ToolConfig = { + LEFT: 'START', + START: 'START', + CENTER: 'CENTER', + RIGHT: 'END', + END: 'END', + JUSTIFY: 'JUSTIFIED', + JUSTIFIED: 'JUSTIFIED', +} + +export const updateParagraphStyleTool: ToolConfig< + GoogleDocsToolParams, + GoogleDocsUpdateParagraphStyleResponse +> = { + id: 'google_docs_update_paragraph_style', + name: 'Update Paragraph Style in Google Docs Document', + description: + 'Apply a named paragraph style (such as a heading or title) and/or alignment to the paragraphs overlapping a range of text in a Google Docs document, identified by its start and end character index.', + version: '1.0', + oauth: { + required: true, + provider: 'google-docs', + }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Google Docs API', + }, + documentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the document to update', + }, + startIndex: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'The start character index (the document body starts at index 1) of the range to style (inclusive)', + }, + endIndex: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The end character index of the range to style (exclusive)', + }, + namedStyleType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The named paragraph style to apply. One of: NORMAL_TEXT, TITLE, SUBTITLE, HEADING_1, HEADING_2, HEADING_3, HEADING_4, HEADING_5, HEADING_6.', + }, + alignment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The paragraph alignment to apply. One of: LEFT, CENTER, RIGHT, JUSTIFY.', + }, + }, + request: { + url: (params) => { + const documentId = resolveDocumentId(params) + return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate` + }, + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + const range = buildContentRange(params.startIndex, params.endIndex) + + const paragraphStyle: Record = {} + const fields: string[] = [] + + if (params.namedStyleType != null && String(params.namedStyleType).trim() !== '') { + const namedStyleType = String(params.namedStyleType).trim().toUpperCase() + if (!NAMED_STYLE_TYPES.has(namedStyleType)) { + throw new Error( + 'namedStyleType must be one of: NORMAL_TEXT, TITLE, SUBTITLE, HEADING_1, HEADING_2, HEADING_3, HEADING_4, HEADING_5, HEADING_6' + ) + } + paragraphStyle.namedStyleType = namedStyleType + fields.push('namedStyleType') + } + + if (params.alignment != null && String(params.alignment).trim() !== '') { + const alignment = ALIGNMENT_MAP[String(params.alignment).trim().toUpperCase()] + if (!alignment) { + throw new Error('alignment must be one of: LEFT, CENTER, RIGHT, JUSTIFY') + } + paragraphStyle.alignment = alignment + fields.push('alignment') + } + + if (fields.length === 0) { + throw new Error('At least one of namedStyleType or alignment must be provided') + } + + return { + requests: [ + { + updateParagraphStyle: { + range, + paragraphStyle, + fields: fields.join(','), + }, + }, + ], + } + }, + }, + + transformResponse: async (response: Response) => { + const responseText = await response.text() + const data = responseText.trim() ? JSON.parse(responseText) : {} + const metadata = buildBatchUpdateMetadata(data, response.url) + + return { + success: true, + output: { + updatedContent: true, + metadata, + }, + } + }, + + outputs: { + updatedContent: { + type: 'boolean', + description: 'Indicates if the paragraph style was applied successfully', + }, + metadata: { + type: 'json', + description: 'Updated document metadata including ID, title, and URL', + properties: { + documentId: { type: 'string', description: 'Google Docs document ID' }, + title: { type: 'string', description: 'Document title' }, + mimeType: { type: 'string', description: 'Document MIME type' }, + url: { type: 'string', description: 'Document URL' }, + }, + }, + }, +} diff --git a/apps/sim/tools/google_docs/update-text-style.ts b/apps/sim/tools/google_docs/update-text-style.ts index 435737659d4..0dad5d1dfe0 100644 --- a/apps/sim/tools/google_docs/update-text-style.ts +++ b/apps/sim/tools/google_docs/update-text-style.ts @@ -39,7 +39,8 @@ export const updateTextStyleTool: ToolConfig< type: 'number', required: true, visibility: 'user-or-llm', - description: 'The 1-based start character index of the range to style (inclusive)', + description: + 'The start character index (the document body starts at index 1) of the range to style (inclusive)', }, endIndex: { type: 'number', diff --git a/apps/sim/tools/google_docs/utils.ts b/apps/sim/tools/google_docs/utils.ts index f4d040c2f35..5be3c11799a 100644 --- a/apps/sim/tools/google_docs/utils.ts +++ b/apps/sim/tools/google_docs/utils.ts @@ -45,6 +45,27 @@ export function buildInsertLocation( return { endOfSegmentLocation: {} } } +/** + * Build and validate a Docs API `Range` from a start and end character index. + * The Docs API requires `endIndex` to be strictly greater than `startIndex`. + * Throws when either index is missing or the range is empty/inverted so callers + * fail loudly before issuing a request. + */ +export function buildContentRange( + startIndex: unknown, + endIndex: unknown +): { startIndex: number; endIndex: number } { + const start = Number(startIndex) + const end = Number(endIndex) + if (!Number.isFinite(start) || !Number.isFinite(end)) { + throw new Error('startIndex and endIndex are required') + } + if (end <= start) { + throw new Error('endIndex must be greater than startIndex') + } + return { startIndex: start, endIndex: end } +} + /** * Build canonical Google Docs metadata from a batchUpdate response. The * `documentId` is taken from the response body when present, otherwise parsed diff --git a/apps/sim/tools/jira/get_fields.ts b/apps/sim/tools/jira/get_fields.ts new file mode 100644 index 00000000000..6a15b0a3c2c --- /dev/null +++ b/apps/sim/tools/jira/get_fields.ts @@ -0,0 +1,153 @@ +import type { JiraGetFieldsParams, JiraGetFieldsResponse } from '@/tools/jira/types' +import { TIMESTAMP_OUTPUT } from '@/tools/jira/types' +import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import type { ToolConfig } from '@/tools/types' + +function buildFieldsUrl(cloudId: string): string { + return `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/field` +} + +export const jiraGetFieldsTool: ToolConfig = { + id: 'jira_get_fields', + name: 'Jira Get Fields', + description: + 'Get all system and custom fields defined in the Jira instance. Useful for discovering custom field IDs (e.g., customfield_10001) to use when writing or updating issues.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.', + }, + }, + + request: { + url: (params: JiraGetFieldsParams) => { + if (params.cloudId) { + return buildFieldsUrl(params.cloudId) + } + return 'https://api.atlassian.com/oauth/token/accessible-resources' + }, + method: 'GET', + headers: (params: JiraGetFieldsParams) => ({ + Accept: 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response: Response, params?: JiraGetFieldsParams) => { + const fetchFields = async (cloudId: string) => { + const fieldsResponse = await fetch(buildFieldsUrl(cloudId), { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${params!.accessToken}`, + }, + }) + + if (!fieldsResponse.ok) { + const errorText = await fieldsResponse.text() + throw new Error( + parseAtlassianErrorMessage(fieldsResponse.status, fieldsResponse.statusText, errorText) + ) + } + + return fieldsResponse.json() + } + + let data: any + + if (!params?.cloudId) { + const cloudId = await getJiraCloudId(params!.domain, params!.accessToken) + data = await fetchFields(cloudId) + } else { + if (!response.ok) { + const errorText = await response.text() + throw new Error(parseAtlassianErrorMessage(response.status, response.statusText, errorText)) + } + data = await response.json() + } + + const fields = Array.isArray(data) ? data : [] + + return { + success: true, + output: { + ts: new Date().toISOString(), + fields: fields.map((f: any) => ({ + id: f?.id ?? '', + key: f?.key ?? null, + name: f?.name ?? '', + custom: f?.custom ?? null, + navigable: f?.navigable ?? null, + searchable: f?.searchable ?? null, + schemaType: f?.schema?.type ?? null, + customType: f?.schema?.custom ?? null, + })), + total: fields.length, + }, + } + }, + + outputs: { + ts: TIMESTAMP_OUTPUT, + fields: { + type: 'array', + description: 'Array of Jira fields (system and custom)', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Field ID (e.g., summary, customfield_10001)' }, + key: { type: 'string', description: 'Field key', optional: true }, + name: { type: 'string', description: 'Human-readable field name' }, + custom: { + type: 'boolean', + description: 'Whether this is a custom field', + optional: true, + }, + navigable: { + type: 'boolean', + description: 'Whether the field is navigable in issue views', + optional: true, + }, + searchable: { + type: 'boolean', + description: 'Whether the field can be used in JQL searches', + optional: true, + }, + schemaType: { + type: 'string', + description: 'Field value type (e.g., string, number, array, user)', + optional: true, + }, + customType: { + type: 'string', + description: 'Custom field type identifier (only for custom fields)', + optional: true, + }, + }, + }, + }, + total: { type: 'number', description: 'Number of fields returned' }, + }, +} diff --git a/apps/sim/tools/jira/get_project.ts b/apps/sim/tools/jira/get_project.ts new file mode 100644 index 00000000000..6e4db960d15 --- /dev/null +++ b/apps/sim/tools/jira/get_project.ts @@ -0,0 +1,173 @@ +import type { JiraGetProjectParams, JiraGetProjectResponse } from '@/tools/jira/types' +import { TIMESTAMP_OUTPUT } from '@/tools/jira/types' +import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import type { ToolConfig } from '@/tools/types' + +function buildProjectUrl(cloudId: string, projectIdOrKey: string): string { + return `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/project/${encodeURIComponent(projectIdOrKey)}` +} + +export const jiraGetProjectTool: ToolConfig = { + id: 'jira_get_project', + name: 'Jira Get Project', + description: + 'Get the details of a single Jira project by its ID or key, including its type, lead, components, issue types, and versions.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + projectId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The project ID or key (e.g., "PROJ" or "10000")', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.', + }, + }, + + request: { + url: (params: JiraGetProjectParams) => { + if (params.cloudId) { + return buildProjectUrl(params.cloudId, params.projectId) + } + return 'https://api.atlassian.com/oauth/token/accessible-resources' + }, + method: 'GET', + headers: (params: JiraGetProjectParams) => ({ + Accept: 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response: Response, params?: JiraGetProjectParams) => { + const fetchProject = async (cloudId: string) => { + const projectResponse = await fetch(buildProjectUrl(cloudId, params!.projectId), { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${params!.accessToken}`, + }, + }) + + if (!projectResponse.ok) { + const errorText = await projectResponse.text() + throw new Error( + parseAtlassianErrorMessage(projectResponse.status, projectResponse.statusText, errorText) + ) + } + + return projectResponse.json() + } + + let data: any + + if (!params?.cloudId) { + const cloudId = await getJiraCloudId(params!.domain, params!.accessToken) + data = await fetchProject(cloudId) + } else { + if (!response.ok) { + const errorText = await response.text() + throw new Error(parseAtlassianErrorMessage(response.status, response.statusText, errorText)) + } + data = await response.json() + } + + return { + success: true, + output: { + ts: new Date().toISOString(), + id: data?.id ?? '', + key: data?.key ?? '', + name: data?.name ?? '', + description: data?.description ?? null, + projectTypeKey: data?.projectTypeKey ?? null, + simplified: data?.simplified ?? null, + style: data?.style ?? null, + isPrivate: data?.isPrivate ?? null, + url: data?.self ?? null, + leadDisplayName: data?.lead?.displayName ?? null, + leadAccountId: data?.lead?.accountId ?? null, + issueTypes: Array.isArray(data?.issueTypes) + ? data.issueTypes.map((t: any) => ({ + id: t?.id ?? '', + name: t?.name ?? '', + subtask: t?.subtask ?? null, + })) + : [], + }, + } + }, + + outputs: { + ts: TIMESTAMP_OUTPUT, + id: { type: 'string', description: 'Project ID' }, + key: { type: 'string', description: 'Project key (e.g., PROJ)' }, + name: { type: 'string', description: 'Project name' }, + description: { type: 'string', description: 'Project description', optional: true }, + projectTypeKey: { + type: 'string', + description: 'Project type key (e.g., software, service_desk, business)', + optional: true, + }, + simplified: { + type: 'boolean', + description: 'Whether the project is a simplified (team-managed) project', + optional: true, + }, + style: { + type: 'string', + description: 'Project style (e.g., classic, next-gen)', + optional: true, + }, + isPrivate: { type: 'boolean', description: 'Whether the project is private', optional: true }, + url: { type: 'string', description: 'REST API URL for this project', optional: true }, + leadDisplayName: { + type: 'string', + description: 'Display name of the project lead', + optional: true, + }, + leadAccountId: { + type: 'string', + description: 'Account ID of the project lead', + optional: true, + }, + issueTypes: { + type: 'array', + description: 'Issue types available in this project', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Issue type ID' }, + name: { type: 'string', description: 'Issue type name (e.g., Task, Bug, Story)' }, + subtask: { + type: 'boolean', + description: 'Whether this issue type is a subtask', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/jira/get_transitions.ts b/apps/sim/tools/jira/get_transitions.ts new file mode 100644 index 00000000000..94f92df44dc --- /dev/null +++ b/apps/sim/tools/jira/get_transitions.ts @@ -0,0 +1,166 @@ +import type { JiraGetTransitionsParams, JiraGetTransitionsResponse } from '@/tools/jira/types' +import { TIMESTAMP_OUTPUT } from '@/tools/jira/types' +import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import type { ToolConfig } from '@/tools/types' + +function buildTransitionsUrl(cloudId: string, issueKey: string): string { + return `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${encodeURIComponent(issueKey)}/transitions` +} + +export const jiraGetTransitionsTool: ToolConfig< + JiraGetTransitionsParams, + JiraGetTransitionsResponse +> = { + id: 'jira_get_transitions', + name: 'Jira Get Transitions', + description: + 'Get the workflow transitions available for an issue in its current status. Use the returned transition IDs with the Transition Issue operation.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + issueKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The issue key or ID (e.g., PROJ-123)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.', + }, + }, + + request: { + url: (params: JiraGetTransitionsParams) => { + if (params.cloudId) { + return buildTransitionsUrl(params.cloudId, params.issueKey) + } + return 'https://api.atlassian.com/oauth/token/accessible-resources' + }, + method: 'GET', + headers: (params: JiraGetTransitionsParams) => ({ + Accept: 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response: Response, params?: JiraGetTransitionsParams) => { + const fetchTransitions = async (cloudId: string) => { + const transitionsResponse = await fetch(buildTransitionsUrl(cloudId, params!.issueKey), { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${params!.accessToken}`, + }, + }) + + if (!transitionsResponse.ok) { + const errorText = await transitionsResponse.text() + throw new Error( + parseAtlassianErrorMessage( + transitionsResponse.status, + transitionsResponse.statusText, + errorText + ) + ) + } + + return transitionsResponse.json() + } + + let data: any + + if (!params?.cloudId) { + const cloudId = await getJiraCloudId(params!.domain, params!.accessToken) + data = await fetchTransitions(cloudId) + } else { + if (!response.ok) { + const errorText = await response.text() + throw new Error(parseAtlassianErrorMessage(response.status, response.statusText, errorText)) + } + data = await response.json() + } + + const transitions = Array.isArray(data?.transitions) ? data.transitions : [] + + return { + success: true, + output: { + ts: new Date().toISOString(), + issueKey: params?.issueKey ?? '', + transitions: transitions.map((t: any) => ({ + id: t?.id ?? '', + name: t?.name ?? '', + toStatusId: t?.to?.id ?? null, + toStatusName: t?.to?.name ?? null, + toStatusCategory: t?.to?.statusCategory?.key ?? null, + isAvailable: t?.isAvailable ?? null, + hasScreen: t?.hasScreen ?? null, + })), + total: transitions.length, + }, + } + }, + + outputs: { + ts: TIMESTAMP_OUTPUT, + issueKey: { type: 'string', description: 'Issue key the transitions belong to' }, + transitions: { + type: 'array', + description: 'Available workflow transitions for the issue', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Transition ID (use with Transition Issue)' }, + name: { type: 'string', description: 'Transition name (e.g., "Start Progress")' }, + toStatusId: { + type: 'string', + description: 'ID of the status the issue moves to', + optional: true, + }, + toStatusName: { + type: 'string', + description: 'Name of the status the issue moves to', + optional: true, + }, + toStatusCategory: { + type: 'string', + description: 'Status category key of the target status (new, indeterminate, done)', + optional: true, + }, + isAvailable: { + type: 'boolean', + description: 'Whether the transition can currently be performed', + optional: true, + }, + hasScreen: { + type: 'boolean', + description: 'Whether the transition requires a screen with fields', + optional: true, + }, + }, + }, + }, + total: { type: 'number', description: 'Number of available transitions' }, + }, +} diff --git a/apps/sim/tools/jira/index.ts b/apps/sim/tools/jira/index.ts index 877fdb8b51d..07f0bfe2b9d 100644 --- a/apps/sim/tools/jira/index.ts +++ b/apps/sim/tools/jira/index.ts @@ -12,8 +12,13 @@ import { jiraDeleteIssueLinkTool } from '@/tools/jira/delete_issue_link' import { jiraDeleteWorklogTool } from '@/tools/jira/delete_worklog' import { jiraGetAttachmentsTool } from '@/tools/jira/get_attachments' import { jiraGetCommentsTool } from '@/tools/jira/get_comments' +import { jiraGetFieldsTool } from '@/tools/jira/get_fields' +import { jiraGetProjectTool } from '@/tools/jira/get_project' +import { jiraGetTransitionsTool } from '@/tools/jira/get_transitions' import { jiraGetUsersTool } from '@/tools/jira/get_users' import { jiraGetWorklogsTool } from '@/tools/jira/get_worklogs' +import { jiraListIssueTypesTool } from '@/tools/jira/list_issue_types' +import { jiraListProjectsTool } from '@/tools/jira/list_projects' import { jiraRemoveWatcherTool } from '@/tools/jira/remove_watcher' import { jiraRetrieveTool } from '@/tools/jira/retrieve' import { jiraSearchIssuesTool } from '@/tools/jira/search_issues' @@ -50,4 +55,9 @@ export { jiraRemoveWatcherTool, jiraGetUsersTool, jiraSearchUsersTool, + jiraListProjectsTool, + jiraGetProjectTool, + jiraGetTransitionsTool, + jiraListIssueTypesTool, + jiraGetFieldsTool, } diff --git a/apps/sim/tools/jira/list_issue_types.ts b/apps/sim/tools/jira/list_issue_types.ts new file mode 100644 index 00000000000..9cf4ed07071 --- /dev/null +++ b/apps/sim/tools/jira/list_issue_types.ts @@ -0,0 +1,150 @@ +import type { JiraListIssueTypesParams, JiraListIssueTypesResponse } from '@/tools/jira/types' +import { TIMESTAMP_OUTPUT } from '@/tools/jira/types' +import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import type { ToolConfig } from '@/tools/types' + +function buildIssueTypesUrl(cloudId: string): string { + return `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issuetype` +} + +export const jiraListIssueTypesTool: ToolConfig< + JiraListIssueTypesParams, + JiraListIssueTypesResponse +> = { + id: 'jira_list_issue_types', + name: 'Jira List Issue Types', + description: + 'List all issue types visible to the user across projects (e.g., Task, Bug, Story, Epic, Subtask). Useful for discovering valid issue types before creating an issue.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.', + }, + }, + + request: { + url: (params: JiraListIssueTypesParams) => { + if (params.cloudId) { + return buildIssueTypesUrl(params.cloudId) + } + return 'https://api.atlassian.com/oauth/token/accessible-resources' + }, + method: 'GET', + headers: (params: JiraListIssueTypesParams) => ({ + Accept: 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response: Response, params?: JiraListIssueTypesParams) => { + const fetchIssueTypes = async (cloudId: string) => { + const issueTypesResponse = await fetch(buildIssueTypesUrl(cloudId), { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${params!.accessToken}`, + }, + }) + + if (!issueTypesResponse.ok) { + const errorText = await issueTypesResponse.text() + throw new Error( + parseAtlassianErrorMessage( + issueTypesResponse.status, + issueTypesResponse.statusText, + errorText + ) + ) + } + + return issueTypesResponse.json() + } + + let data: any + + if (!params?.cloudId) { + const cloudId = await getJiraCloudId(params!.domain, params!.accessToken) + data = await fetchIssueTypes(cloudId) + } else { + if (!response.ok) { + const errorText = await response.text() + throw new Error(parseAtlassianErrorMessage(response.status, response.statusText, errorText)) + } + data = await response.json() + } + + const issueTypes = Array.isArray(data) ? data : [] + + return { + success: true, + output: { + ts: new Date().toISOString(), + issueTypes: issueTypes.map((t: any) => ({ + id: t?.id ?? '', + name: t?.name ?? '', + description: t?.description ?? null, + subtask: t?.subtask ?? null, + hierarchyLevel: typeof t?.hierarchyLevel === 'number' ? t.hierarchyLevel : null, + iconUrl: t?.iconUrl ?? null, + scope: t?.scope?.project?.id ?? null, + })), + total: issueTypes.length, + }, + } + }, + + outputs: { + ts: TIMESTAMP_OUTPUT, + issueTypes: { + type: 'array', + description: 'Array of issue types', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Issue type ID' }, + name: { type: 'string', description: 'Issue type name (e.g., Task, Bug, Story)' }, + description: { type: 'string', description: 'Issue type description', optional: true }, + subtask: { + type: 'boolean', + description: 'Whether this issue type is a subtask', + optional: true, + }, + hierarchyLevel: { + type: 'number', + description: 'Hierarchy level (0 = standard, 1 = epic, -1 = subtask)', + optional: true, + }, + iconUrl: { type: 'string', description: 'URL of the issue type icon', optional: true }, + scope: { + type: 'string', + description: 'Project ID if this issue type is scoped to a team-managed project', + optional: true, + }, + }, + }, + }, + total: { type: 'number', description: 'Number of issue types returned' }, + }, +} diff --git a/apps/sim/tools/jira/list_projects.ts b/apps/sim/tools/jira/list_projects.ts new file mode 100644 index 00000000000..fd892bdf46a --- /dev/null +++ b/apps/sim/tools/jira/list_projects.ts @@ -0,0 +1,208 @@ +import type { JiraListProjectsParams, JiraListProjectsResponse } from '@/tools/jira/types' +import { TIMESTAMP_OUTPUT } from '@/tools/jira/types' +import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' +import type { ToolConfig } from '@/tools/types' + +/** + * Transforms a raw Jira project object into typed output. + */ +function transformProject(project: any) { + return { + id: project.id ?? '', + key: project.key ?? '', + name: project.name ?? '', + projectTypeKey: project.projectTypeKey ?? null, + simplified: project.simplified ?? null, + style: project.style ?? null, + isPrivate: project.isPrivate ?? null, + url: project.self ?? null, + leadDisplayName: project.lead?.displayName ?? null, + leadAccountId: project.lead?.accountId ?? null, + } +} + +function buildSearchUrl(cloudId: string, params: JiraListProjectsParams): string { + const queryParams = new URLSearchParams() + // `lead` is not returned by default on project/search — expand it so the lead outputs populate. + queryParams.append('expand', 'lead') + if (params.query) queryParams.append('query', params.query) + if (params.startAt !== undefined) queryParams.append('startAt', String(params.startAt)) + if (params.maxResults !== undefined) queryParams.append('maxResults', String(params.maxResults)) + const queryString = queryParams.toString() + return `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/project/search${queryString ? `?${queryString}` : ''}` +} + +export const jiraListProjectsTool: ToolConfig = { + id: 'jira_list_projects', + name: 'Jira List Projects', + description: + 'List Jira projects visible to the user, with optional name/key filtering and pagination. Returns each project with id, key, name, and type.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter projects by partial name or key match', + }, + startAt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'The index of the first project to return (for pagination, default: 0)', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of projects to return (default: 50, max: 100)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.', + }, + }, + + request: { + url: (params: JiraListProjectsParams) => { + if (params.cloudId) { + return buildSearchUrl(params.cloudId, params) + } + return 'https://api.atlassian.com/oauth/token/accessible-resources' + }, + method: 'GET', + headers: (params: JiraListProjectsParams) => ({ + Accept: 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response: Response, params?: JiraListProjectsParams) => { + const fetchProjects = async (cloudId: string) => { + const projectsResponse = await fetch(buildSearchUrl(cloudId, params!), { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${params!.accessToken}`, + }, + }) + + if (!projectsResponse.ok) { + const errorText = await projectsResponse.text() + throw new Error( + parseAtlassianErrorMessage( + projectsResponse.status, + projectsResponse.statusText, + errorText + ) + ) + } + + return projectsResponse.json() + } + + let data: any + + if (!params?.cloudId) { + const cloudId = await getJiraCloudId(params!.domain, params!.accessToken) + data = await fetchProjects(cloudId) + } else { + if (!response.ok) { + const errorText = await response.text() + throw new Error(parseAtlassianErrorMessage(response.status, response.statusText, errorText)) + } + data = await response.json() + } + + const values = Array.isArray(data?.values) ? data.values : [] + + return { + success: true, + output: { + ts: new Date().toISOString(), + projects: values.map(transformProject), + total: typeof data?.total === 'number' ? data.total : values.length, + startAt: typeof data?.startAt === 'number' ? data.startAt : (params?.startAt ?? 0), + maxResults: + typeof data?.maxResults === 'number' ? data.maxResults : (params?.maxResults ?? 50), + isLast: data?.isLast ?? null, + }, + } + }, + + outputs: { + ts: TIMESTAMP_OUTPUT, + projects: { + type: 'array', + description: 'Array of Jira projects', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Project ID' }, + key: { type: 'string', description: 'Project key (e.g., PROJ)' }, + name: { type: 'string', description: 'Project name' }, + projectTypeKey: { + type: 'string', + description: 'Project type key (e.g., software, service_desk, business)', + optional: true, + }, + simplified: { + type: 'boolean', + description: 'Whether the project is a simplified (team-managed) project', + optional: true, + }, + style: { + type: 'string', + description: 'Project style (e.g., classic, next-gen)', + optional: true, + }, + isPrivate: { + type: 'boolean', + description: 'Whether the project is private', + optional: true, + }, + url: { type: 'string', description: 'REST API URL for this project', optional: true }, + leadDisplayName: { + type: 'string', + description: 'Display name of the project lead', + optional: true, + }, + leadAccountId: { + type: 'string', + description: 'Account ID of the project lead', + optional: true, + }, + }, + }, + }, + total: { type: 'number', description: 'Total number of matching projects' }, + startAt: { type: 'number', description: 'Pagination start index' }, + maxResults: { type: 'number', description: 'Maximum results per page' }, + isLast: { + type: 'boolean', + description: 'Whether this is the last page of results', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/jira/types.ts b/apps/sim/tools/jira/types.ts index 0c2feee9b62..aec32e52270 100644 --- a/apps/sim/tools/jira/types.ts +++ b/apps/sim/tools/jira/types.ts @@ -1597,6 +1597,135 @@ export interface JiraGetUsersResponse extends ToolResponse { } } +export interface JiraListProjectsParams { + accessToken: string + domain: string + query?: string + startAt?: number + maxResults?: number + cloudId?: string +} + +export interface JiraListProjectsResponse extends ToolResponse { + output: { + ts: string + projects: Array<{ + id: string + key: string + name: string + projectTypeKey?: string | null + simplified?: boolean | null + style?: string | null + isPrivate?: boolean | null + url?: string | null + leadDisplayName?: string | null + leadAccountId?: string | null + }> + total: number + startAt: number + maxResults: number + isLast?: boolean | null + } +} + +export interface JiraGetProjectParams { + accessToken: string + domain: string + projectId: string + cloudId?: string +} + +export interface JiraGetProjectResponse extends ToolResponse { + output: { + ts: string + id: string + key: string + name: string + description?: string | null + projectTypeKey?: string | null + simplified?: boolean | null + style?: string | null + isPrivate?: boolean | null + url?: string | null + leadDisplayName?: string | null + leadAccountId?: string | null + issueTypes: Array<{ + id: string + name: string + subtask?: boolean | null + }> + } +} + +export interface JiraGetTransitionsParams { + accessToken: string + domain: string + issueKey: string + cloudId?: string +} + +export interface JiraGetTransitionsResponse extends ToolResponse { + output: { + ts: string + issueKey: string + transitions: Array<{ + id: string + name: string + toStatusId?: string | null + toStatusName?: string | null + toStatusCategory?: string | null + isAvailable?: boolean | null + hasScreen?: boolean | null + }> + total: number + } +} + +export interface JiraListIssueTypesParams { + accessToken: string + domain: string + cloudId?: string +} + +export interface JiraListIssueTypesResponse extends ToolResponse { + output: { + ts: string + issueTypes: Array<{ + id: string + name: string + description?: string | null + subtask?: boolean | null + hierarchyLevel?: number | null + iconUrl?: string | null + scope?: string | null + }> + total: number + } +} + +export interface JiraGetFieldsParams { + accessToken: string + domain: string + cloudId?: string +} + +export interface JiraGetFieldsResponse extends ToolResponse { + output: { + ts: string + fields: Array<{ + id: string + key?: string | null + name: string + custom?: boolean | null + navigable?: boolean | null + searchable?: boolean | null + schemaType?: string | null + customType?: string | null + }> + total: number + } +} + export type JiraResponse = | JiraRetrieveResponse | JiraUpdateResponse @@ -1623,3 +1752,8 @@ export type JiraResponse = | JiraRemoveWatcherResponse | JiraGetUsersResponse | JiraSearchUsersResponse + | JiraListProjectsResponse + | JiraGetProjectResponse + | JiraGetTransitionsResponse + | JiraListIssueTypesResponse + | JiraGetFieldsResponse diff --git a/apps/sim/tools/monday/change_column_value.ts b/apps/sim/tools/monday/change_column_value.ts new file mode 100644 index 00000000000..3c5ab4ff0ff --- /dev/null +++ b/apps/sim/tools/monday/change_column_value.ts @@ -0,0 +1,160 @@ +import type { + MondayChangeColumnValueParams, + MondayChangeColumnValueResponse, +} from '@/tools/monday/types' +import { + extractMondayError, + MONDAY_API_URL, + mondayHeaders, + sanitizeNumericId, +} from '@/tools/monday/utils' +import type { ToolConfig } from '@/tools/types' + +export const mondayChangeColumnValueTool: ToolConfig< + MondayChangeColumnValueParams, + MondayChangeColumnValueResponse +> = { + id: 'monday_change_column_value', + name: 'Monday Change Column Value', + description: "Update a single column's value on a Monday.com item", + version: '1.0.0', + + oauth: { + required: true, + provider: 'monday', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Monday.com OAuth access token', + }, + boardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the board containing the item', + }, + itemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the item to update', + }, + columnId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the column to update (e.g., "status", "date4")', + }, + value: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The new column value as a JSON string (e.g., {"label":"Done"} for a status column)', + }, + createLabelsIfMissing: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Create status/dropdown labels that do not yet exist on the column', + }, + }, + + request: { + url: MONDAY_API_URL, + method: 'POST', + headers: (params) => mondayHeaders(params.accessToken), + body: (params) => { + const args: string[] = [ + `board_id: ${sanitizeNumericId(params.boardId, 'boardId')}`, + `item_id: ${sanitizeNumericId(params.itemId, 'itemId')}`, + `column_id: ${JSON.stringify(params.columnId)}`, + `value: ${JSON.stringify(params.value)}`, + ] + if (params.createLabelsIfMissing) { + args.push('create_labels_if_missing: true') + } + return { + query: `mutation { change_column_value(${args.join(', ')}) { id name state board { id } group { id title } column_values { id text value type } created_at updated_at url } }`, + } + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + const error = extractMondayError(data) + if (error) { + return { success: false, output: { item: null }, error } + } + + const raw = data.data?.change_column_value + if (!raw) { + return { success: false, output: { item: null }, error: 'Failed to change column value' } + } + + const board = raw.board as Record | null + const group = raw.group as Record | null + const columnValues = ((raw.column_values as Record[]) ?? []).map( + (cv: Record) => ({ + id: cv.id as string, + text: (cv.text as string) ?? null, + value: (cv.value as string) ?? null, + type: (cv.type as string) ?? '', + }) + ) + + return { + success: true, + output: { + item: { + id: raw.id as string, + name: (raw.name as string) ?? '', + state: (raw.state as string) ?? null, + boardId: board ? (board.id as string) : null, + groupId: group ? (group.id as string) : null, + groupTitle: group ? ((group.title as string) ?? null) : null, + columnValues, + createdAt: (raw.created_at as string) ?? null, + updatedAt: (raw.updated_at as string) ?? null, + url: (raw.url as string) ?? null, + }, + }, + } + }, + + outputs: { + item: { + type: 'json', + description: 'The updated item', + optional: true, + properties: { + id: { type: 'string', description: 'Item ID' }, + name: { type: 'string', description: 'Item name' }, + state: { type: 'string', description: 'Item state', optional: true }, + boardId: { type: 'string', description: 'Board ID', optional: true }, + groupId: { type: 'string', description: 'Group ID', optional: true }, + groupTitle: { type: 'string', description: 'Group title', optional: true }, + columnValues: { + type: 'array', + description: 'Column values', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Column ID' }, + text: { type: 'string', description: 'Text value', optional: true }, + value: { type: 'string', description: 'Raw JSON value', optional: true }, + type: { type: 'string', description: 'Column type' }, + }, + }, + }, + createdAt: { type: 'string', description: 'Creation timestamp', optional: true }, + updatedAt: { type: 'string', description: 'Last updated timestamp', optional: true }, + url: { type: 'string', description: 'Item URL', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/monday/create_board.ts b/apps/sim/tools/monday/create_board.ts new file mode 100644 index 00000000000..f0f44f3d309 --- /dev/null +++ b/apps/sim/tools/monday/create_board.ts @@ -0,0 +1,134 @@ +import type { MondayCreateBoardParams, MondayCreateBoardResponse } from '@/tools/monday/types' +import { + extractMondayError, + MONDAY_API_URL, + mondayHeaders, + sanitizeEnum, + sanitizeNumericId, +} from '@/tools/monday/utils' +import type { ToolConfig } from '@/tools/types' + +const BOARD_KINDS = ['public', 'private', 'share'] as const + +export const mondayCreateBoardTool: ToolConfig = + { + id: 'monday_create_board', + name: 'Monday Create Board', + description: 'Create a new board in Monday.com', + version: '1.0.0', + + oauth: { + required: true, + provider: 'monday', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Monday.com OAuth access token', + }, + boardName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the new board', + }, + boardKind: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The board kind: public, private, or share', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The board description', + }, + workspaceId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The ID of the workspace to create the board in', + }, + folderId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The ID of the folder to create the board in', + }, + }, + + request: { + url: MONDAY_API_URL, + method: 'POST', + headers: (params) => mondayHeaders(params.accessToken), + body: (params) => { + const args: string[] = [ + `board_name: ${JSON.stringify(params.boardName)}`, + `board_kind: ${sanitizeEnum(params.boardKind, 'boardKind', BOARD_KINDS)}`, + ] + if (params.description) { + args.push(`description: ${JSON.stringify(params.description)}`) + } + if (params.workspaceId) { + args.push(`workspace_id: ${sanitizeNumericId(params.workspaceId, 'workspaceId')}`) + } + if (params.folderId) { + args.push(`folder_id: ${sanitizeNumericId(params.folderId, 'folderId')}`) + } + return { + query: `mutation { create_board(${args.join(', ')}) { id name description state board_kind items_count url updated_at } }`, + } + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + const error = extractMondayError(data) + if (error) { + return { success: false, output: { board: null }, error } + } + + const raw = data.data?.create_board + if (!raw) { + return { success: false, output: { board: null }, error: 'Failed to create board' } + } + + return { + success: true, + output: { + board: { + id: raw.id as string, + name: (raw.name as string) ?? '', + description: (raw.description as string) ?? null, + state: (raw.state as string) ?? 'active', + boardKind: (raw.board_kind as string) ?? 'public', + itemsCount: (raw.items_count as number) ?? 0, + url: (raw.url as string) ?? '', + updatedAt: (raw.updated_at as string) ?? null, + }, + }, + } + }, + + outputs: { + board: { + type: 'json', + description: 'The created board', + optional: true, + properties: { + id: { type: 'string', description: 'Board ID' }, + name: { type: 'string', description: 'Board name' }, + description: { type: 'string', description: 'Board description', optional: true }, + state: { type: 'string', description: 'Board state' }, + boardKind: { type: 'string', description: 'Board kind (public, private, share)' }, + itemsCount: { type: 'number', description: 'Number of items' }, + url: { type: 'string', description: 'Board URL' }, + updatedAt: { type: 'string', description: 'Last updated timestamp', optional: true }, + }, + }, + }, + } diff --git a/apps/sim/tools/monday/create_column.ts b/apps/sim/tools/monday/create_column.ts new file mode 100644 index 00000000000..fdd360be6f6 --- /dev/null +++ b/apps/sim/tools/monday/create_column.ts @@ -0,0 +1,159 @@ +import type { MondayCreateColumnParams, MondayCreateColumnResponse } from '@/tools/monday/types' +import { + extractMondayError, + MONDAY_API_URL, + mondayHeaders, + sanitizeEnum, + sanitizeNumericId, +} from '@/tools/monday/utils' +import type { ToolConfig } from '@/tools/types' + +const COLUMN_TYPES = [ + 'auto_number', + 'board_relation', + 'button', + 'checkbox', + 'color_picker', + 'country', + 'date', + 'dependency', + 'doc', + 'dropdown', + 'email', + 'file', + 'formula', + 'hour', + 'item_id', + 'link', + 'location', + 'long_text', + 'mirror', + 'name', + 'numbers', + 'people', + 'phone', + 'progress', + 'rating', + 'status', + 'tags', + 'team', + 'text', + 'timeline', + 'time_tracking', + 'vote', + 'week', + 'world_clock', +] as const + +export const mondayCreateColumnTool: ToolConfig< + MondayCreateColumnParams, + MondayCreateColumnResponse +> = { + id: 'monday_create_column', + name: 'Monday Create Column', + description: 'Create a new column on a Monday.com board', + version: '1.0.0', + + oauth: { + required: true, + provider: 'monday', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Monday.com OAuth access token', + }, + boardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the board to create the column on', + }, + columnTitle: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The title of the new column', + }, + columnType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The column type (e.g., status, text, numbers, date, people, dropdown)', + }, + columnDescription: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The column description', + }, + columnDefaults: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON string of default settings for the column (e.g., status labels)', + }, + }, + + request: { + url: MONDAY_API_URL, + method: 'POST', + headers: (params) => mondayHeaders(params.accessToken), + body: (params) => { + const args: string[] = [ + `board_id: ${sanitizeNumericId(params.boardId, 'boardId')}`, + `title: ${JSON.stringify(params.columnTitle)}`, + `column_type: ${sanitizeEnum(params.columnType, 'columnType', COLUMN_TYPES)}`, + ] + if (params.columnDescription) { + args.push(`description: ${JSON.stringify(params.columnDescription)}`) + } + if (params.columnDefaults) { + args.push(`defaults: ${JSON.stringify(params.columnDefaults)}`) + } + return { + query: `mutation { create_column(${args.join(', ')}) { id title type } }`, + } + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + const error = extractMondayError(data) + if (error) { + return { success: false, output: { column: null }, error } + } + + const raw = data.data?.create_column + if (!raw) { + return { success: false, output: { column: null }, error: 'Failed to create column' } + } + + return { + success: true, + output: { + column: { + id: raw.id as string, + title: (raw.title as string) ?? '', + type: (raw.type as string) ?? '', + }, + }, + } + }, + + outputs: { + column: { + type: 'json', + description: 'The created column', + optional: true, + properties: { + id: { type: 'string', description: 'Column ID' }, + title: { type: 'string', description: 'Column title' }, + type: { type: 'string', description: 'Column type' }, + }, + }, + }, +} diff --git a/apps/sim/tools/monday/duplicate_item.ts b/apps/sim/tools/monday/duplicate_item.ts new file mode 100644 index 00000000000..7d2496ebd68 --- /dev/null +++ b/apps/sim/tools/monday/duplicate_item.ts @@ -0,0 +1,142 @@ +import type { MondayDuplicateItemParams, MondayDuplicateItemResponse } from '@/tools/monday/types' +import { + extractMondayError, + MONDAY_API_URL, + mondayHeaders, + sanitizeNumericId, +} from '@/tools/monday/utils' +import type { ToolConfig } from '@/tools/types' + +export const mondayDuplicateItemTool: ToolConfig< + MondayDuplicateItemParams, + MondayDuplicateItemResponse +> = { + id: 'monday_duplicate_item', + name: 'Monday Duplicate Item', + description: 'Duplicate an existing item on a Monday.com board', + version: '1.0.0', + + oauth: { + required: true, + provider: 'monday', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Monday.com OAuth access token', + }, + boardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the board containing the item', + }, + itemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the item to duplicate', + }, + withUpdates: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to also duplicate the item updates', + }, + }, + + request: { + url: MONDAY_API_URL, + method: 'POST', + headers: (params) => mondayHeaders(params.accessToken), + body: (params) => { + const args: string[] = [ + `board_id: ${sanitizeNumericId(params.boardId, 'boardId')}`, + `item_id: ${sanitizeNumericId(params.itemId, 'itemId')}`, + ] + if (params.withUpdates) { + args.push('with_updates: true') + } + return { + query: `mutation { duplicate_item(${args.join(', ')}) { id name state board { id } group { id title } column_values { id text value type } created_at updated_at url } }`, + } + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + const error = extractMondayError(data) + if (error) { + return { success: false, output: { item: null }, error } + } + + const raw = data.data?.duplicate_item + if (!raw) { + return { success: false, output: { item: null }, error: 'Failed to duplicate item' } + } + + const board = raw.board as Record | null + const group = raw.group as Record | null + const columnValues = ((raw.column_values as Record[]) ?? []).map( + (cv: Record) => ({ + id: cv.id as string, + text: (cv.text as string) ?? null, + value: (cv.value as string) ?? null, + type: (cv.type as string) ?? '', + }) + ) + + return { + success: true, + output: { + item: { + id: raw.id as string, + name: (raw.name as string) ?? '', + state: (raw.state as string) ?? null, + boardId: board ? (board.id as string) : null, + groupId: group ? (group.id as string) : null, + groupTitle: group ? ((group.title as string) ?? null) : null, + columnValues, + createdAt: (raw.created_at as string) ?? null, + updatedAt: (raw.updated_at as string) ?? null, + url: (raw.url as string) ?? null, + }, + }, + } + }, + + outputs: { + item: { + type: 'json', + description: 'The duplicated item', + optional: true, + properties: { + id: { type: 'string', description: 'Item ID' }, + name: { type: 'string', description: 'Item name' }, + state: { type: 'string', description: 'Item state', optional: true }, + boardId: { type: 'string', description: 'Board ID', optional: true }, + groupId: { type: 'string', description: 'Group ID', optional: true }, + groupTitle: { type: 'string', description: 'Group title', optional: true }, + columnValues: { + type: 'array', + description: 'Column values', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Column ID' }, + text: { type: 'string', description: 'Text value', optional: true }, + value: { type: 'string', description: 'Raw JSON value', optional: true }, + type: { type: 'string', description: 'Column type' }, + }, + }, + }, + createdAt: { type: 'string', description: 'Creation timestamp', optional: true }, + updatedAt: { type: 'string', description: 'Last updated timestamp', optional: true }, + url: { type: 'string', description: 'Item URL', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/monday/get_groups.ts b/apps/sim/tools/monday/get_groups.ts new file mode 100644 index 00000000000..7764a076d67 --- /dev/null +++ b/apps/sim/tools/monday/get_groups.ts @@ -0,0 +1,94 @@ +import type { MondayGetGroupsParams, MondayGetGroupsResponse } from '@/tools/monday/types' +import { + extractMondayError, + MONDAY_API_URL, + mondayHeaders, + sanitizeNumericId, +} from '@/tools/monday/utils' +import type { ToolConfig } from '@/tools/types' + +export const mondayGetGroupsTool: ToolConfig = { + id: 'monday_get_groups', + name: 'Monday Get Groups', + description: 'Get the groups on a Monday.com board', + version: '1.0.0', + + oauth: { + required: true, + provider: 'monday', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Monday.com OAuth access token', + }, + boardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the board to retrieve groups from', + }, + }, + + request: { + url: MONDAY_API_URL, + method: 'POST', + headers: (params) => mondayHeaders(params.accessToken), + body: (params) => ({ + query: `query { boards(ids: [${sanitizeNumericId(params.boardId, 'boardId')}]) { groups { id title color archived deleted position } } }`, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + const error = extractMondayError(data) + if (error) { + return { success: false, output: { groups: [], count: 0 }, error } + } + + const boards = data.data?.boards ?? [] + if (boards.length === 0) { + return { success: false, output: { groups: [], count: 0 }, error: 'Board not found' } + } + + const groups = (boards[0].groups ?? []).map((g: Record) => ({ + id: g.id as string, + title: (g.title as string) ?? '', + color: (g.color as string) ?? '', + archived: (g.archived as boolean) ?? null, + deleted: (g.deleted as boolean) ?? null, + position: (g.position as string) ?? '', + })) + + return { + success: true, + output: { groups, count: groups.length }, + } + }, + + outputs: { + groups: { + type: 'array', + description: 'Groups on the board', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Group ID' }, + title: { type: 'string', description: 'Group title' }, + color: { type: 'string', description: 'Group color (hex)' }, + archived: { + type: 'boolean', + description: 'Whether the group is archived', + optional: true, + }, + deleted: { type: 'boolean', description: 'Whether the group is deleted', optional: true }, + position: { type: 'string', description: 'Group position' }, + }, + }, + }, + count: { type: 'number', description: 'Number of returned groups' }, + }, +} diff --git a/apps/sim/tools/monday/index.ts b/apps/sim/tools/monday/index.ts index 417c7daa810..907a9183a9c 100644 --- a/apps/sim/tools/monday/index.ts +++ b/apps/sim/tools/monday/index.ts @@ -1,10 +1,15 @@ export { mondayArchiveItemTool } from '@/tools/monday/archive_item' +export { mondayChangeColumnValueTool } from '@/tools/monday/change_column_value' +export { mondayCreateBoardTool } from '@/tools/monday/create_board' +export { mondayCreateColumnTool } from '@/tools/monday/create_column' export { mondayCreateGroupTool } from '@/tools/monday/create_group' export { mondayCreateItemTool } from '@/tools/monday/create_item' export { mondayCreateSubitemTool } from '@/tools/monday/create_subitem' export { mondayCreateUpdateTool } from '@/tools/monday/create_update' export { mondayDeleteItemTool } from '@/tools/monday/delete_item' +export { mondayDuplicateItemTool } from '@/tools/monday/duplicate_item' export { mondayGetBoardTool } from '@/tools/monday/get_board' +export { mondayGetGroupsTool } from '@/tools/monday/get_groups' export { mondayGetItemTool } from '@/tools/monday/get_item' export { mondayGetItemsTool } from '@/tools/monday/get_items' export { mondayListBoardsTool } from '@/tools/monday/list_boards' diff --git a/apps/sim/tools/monday/search_items.ts b/apps/sim/tools/monday/search_items.ts index 0349fe56150..c1e2953e227 100644 --- a/apps/sim/tools/monday/search_items.ts +++ b/apps/sim/tools/monday/search_items.ts @@ -66,19 +66,36 @@ export const mondaySearchItemsTool: ToolConfig { + const column = entry as { column_id?: unknown; column_values?: unknown } + const columnId = JSON.stringify(String(column.column_id ?? '')) + const rawValues = Array.isArray(column.column_values) + ? column.column_values + : [column.column_values] + const columnValues = JSON.stringify(rawValues.map((value) => String(value))) + return `{ column_id: ${columnId}, column_values: ${columnValues} }` + }) + .join(', ')}]` return { - query: `query { items_page_by_column_values(limit: ${limit}, board_id: ${boardId}, columns: ${columnsJson}) { cursor items { id name state board { id } group { id title } column_values { id text value type } created_at updated_at url } } }`, + query: `query { items_page_by_column_values(limit: ${limit}, board_id: ${boardId}, columns: ${columnsLiteral}) { cursor items { id name state board { id } group { id title } column_values { id text value type } created_at updated_at url } } }`, } }, }, diff --git a/apps/sim/tools/monday/types.ts b/apps/sim/tools/monday/types.ts index dd4cafc4b98..12ecbc8fe00 100644 --- a/apps/sim/tools/monday/types.ts +++ b/apps/sim/tools/monday/types.ts @@ -220,3 +220,73 @@ export interface MondayCreateGroupResponse extends ToolResponse { group: MondayGroup | null } } + +export interface MondayChangeColumnValueParams { + accessToken: string + boardId: string + itemId: string + columnId: string + value: string + createLabelsIfMissing?: boolean +} + +export interface MondayChangeColumnValueResponse extends ToolResponse { + output: { + item: MondayItem | null + } +} + +export interface MondayCreateBoardParams { + accessToken: string + boardName: string + boardKind: string + description?: string + workspaceId?: string + folderId?: string +} + +export interface MondayCreateBoardResponse extends ToolResponse { + output: { + board: MondayBoard | null + } +} + +export interface MondayCreateColumnParams { + accessToken: string + boardId: string + columnTitle: string + columnType: string + columnDescription?: string + columnDefaults?: string +} + +export interface MondayCreateColumnResponse extends ToolResponse { + output: { + column: MondayColumn | null + } +} + +export interface MondayGetGroupsParams { + accessToken: string + boardId: string +} + +export interface MondayGetGroupsResponse extends ToolResponse { + output: { + groups: MondayGroup[] + count: number + } +} + +export interface MondayDuplicateItemParams { + accessToken: string + boardId: string + itemId: string + withUpdates?: boolean +} + +export interface MondayDuplicateItemResponse extends ToolResponse { + output: { + item: MondayItem | null + } +} diff --git a/apps/sim/tools/monday/utils.ts b/apps/sim/tools/monday/utils.ts index 3722b01f2ec..f6b130ace96 100644 --- a/apps/sim/tools/monday/utils.ts +++ b/apps/sim/tools/monday/utils.ts @@ -31,6 +31,24 @@ export function sanitizeLimit(value: number | undefined, defaultVal: number, max return Math.min(n, max) } +/** + * Validates a GraphQL enum literal (e.g., board_kind, column_type) against an + * allowlist and returns the bare, unquoted value for safe inlining. GraphQL + * enums must NOT be JSON-stringified; this guards against query injection by + * rejecting anything outside the provided set. + */ +export function sanitizeEnum( + value: string | undefined, + paramName: string, + allowed: readonly string[] +): string { + const normalized = typeof value === 'string' ? value.trim() : '' + if (!allowed.includes(normalized)) { + throw new Error(`Invalid ${paramName}: "${value}". Expected one of: ${allowed.join(', ')}`) + } + return normalized +} + export function extractMondayError(data: Record): string | null { if (data.errors && Array.isArray(data.errors) && data.errors.length > 0) { const messages = (data.errors as Array>) diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index a7f50264ed3..2bb910c4eef 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -183,9 +183,17 @@ import { import { arxivGetAuthorPapersTool, arxivGetPaperTool, arxivSearchTool } from '@/tools/arxiv' import { asanaAddCommentTool, + asanaAddFollowersTool, + asanaCreateProjectTool, + asanaCreateSectionTool, + asanaCreateSubtaskTool, asanaCreateTaskTool, + asanaDeleteTaskTool, asanaGetProjectsTool, + asanaGetProjectTool, asanaGetTaskTool, + asanaListSectionsTool, + asanaListWorkspacesTool, asanaSearchTasksTool, asanaUpdateTaskTool, } from '@/tools/asana' @@ -1265,13 +1273,19 @@ import { googleContactsUpdateTool, } from '@/tools/google_contacts' import { + googleDocsCreateNamedRangeTool, + googleDocsCreateParagraphBulletsTool, googleDocsCreateTool, + googleDocsDeleteContentRangeTool, + googleDocsDeleteNamedRangeTool, + googleDocsDeleteParagraphBulletsTool, googleDocsInsertImageTool, googleDocsInsertPageBreakTool, googleDocsInsertTableTool, googleDocsInsertTextTool, googleDocsReadTool, googleDocsReplaceTextTool, + googleDocsUpdateParagraphStyleTool, googleDocsUpdateTextStyleTool, googleDocsWriteTool, } from '@/tools/google_docs' @@ -1760,8 +1774,13 @@ import { jiraDeleteWorklogTool, jiraGetAttachmentsTool, jiraGetCommentsTool, + jiraGetFieldsTool, + jiraGetProjectTool, + jiraGetTransitionsTool, jiraGetUsersTool, jiraGetWorklogsTool, + jiraListIssueTypesTool, + jiraListProjectsTool, jiraRemoveWatcherTool, jiraRetrieveTool, jiraSearchIssuesTool, @@ -2242,12 +2261,17 @@ import { import { mistralParserTool, mistralParserV2Tool, mistralParserV3Tool } from '@/tools/mistral' import { mondayArchiveItemTool, + mondayChangeColumnValueTool, + mondayCreateBoardTool, + mondayCreateColumnTool, mondayCreateGroupTool, mondayCreateItemTool, mondayCreateSubitemTool, mondayCreateUpdateTool, mondayDeleteItemTool, + mondayDuplicateItemTool, mondayGetBoardTool, + mondayGetGroupsTool, mondayGetItemsTool, mondayGetItemTool, mondayListBoardsTool, @@ -3180,11 +3204,13 @@ import { } from '@/tools/sixtyfour' import { slackAddReactionTool, + slackArchiveConversationTool, slackCanvasTool, slackCreateChannelCanvasTool, slackCreateConversationTool, slackDeleteCanvasTool, slackDeleteMessageTool, + slackDeleteScheduledMessageTool, slackDownloadTool, slackEditCanvasTool, slackEphemeralMessageTool, @@ -3201,6 +3227,7 @@ import { slackListCanvasesTool, slackListChannelsTool, slackListMembersTool, + slackListScheduledMessagesTool, slackListUsersTool, slackLookupCanvasSectionsTool, slackMessageReaderTool, @@ -3209,6 +3236,10 @@ import { slackPublishViewTool, slackPushViewTool, slackRemoveReactionTool, + slackRenameConversationTool, + slackScheduleMessageTool, + slackSetConversationPurposeTool, + slackSetConversationTopicTool, slackSetStatusTool, slackSetSuggestedPromptsTool, slackSetTitleTool, @@ -3814,9 +3845,16 @@ import { tinybirdTruncateDatasourceTool, } from '@/tools/tinybird' import { + trelloAddChecklistTool, trelloAddCommentTool, + trelloAddLabelTool, + trelloAddMemberTool, + trelloCreateBoardTool, trelloCreateCardTool, + trelloCreateListTool, trelloGetActionsTool, + trelloGetBoardTool, + trelloGetCardTool, trelloListCardsTool, trelloListListsTool, trelloUpdateCardTool, @@ -4295,6 +4333,14 @@ export const tools: Record = { asana_get_task: asanaGetTaskTool, asana_search_tasks: asanaSearchTasksTool, asana_update_task: asanaUpdateTaskTool, + asana_add_followers: asanaAddFollowersTool, + asana_create_project: asanaCreateProjectTool, + asana_create_section: asanaCreateSectionTool, + asana_create_subtask: asanaCreateSubtaskTool, + asana_delete_task: asanaDeleteTaskTool, + asana_get_project: asanaGetProjectTool, + asana_list_sections: asanaListSectionsTool, + asana_list_workspaces: asanaListWorkspacesTool, ashby_add_candidate_tag: ashbyAddCandidateTagTool, ashby_change_application_stage: ashbyChangeApplicationStageTool, ashby_create_application: ashbyCreateApplicationTool, @@ -4947,6 +4993,11 @@ export const tools: Record = { jira_remove_watcher: jiraRemoveWatcherTool, jira_get_users: jiraGetUsersTool, jira_search_users: jiraSearchUsersTool, + jira_list_projects: jiraListProjectsTool, + jira_get_project: jiraGetProjectTool, + jira_get_transitions: jiraGetTransitionsTool, + jira_list_issue_types: jiraListIssueTypesTool, + jira_get_fields: jiraGetFieldsTool, jsm_get_service_desks: jsmGetServiceDesksTool, jsm_get_request_types: jsmGetRequestTypesTool, jsm_get_request_type_fields: jsmGetRequestTypeFieldsTool, @@ -5089,6 +5140,13 @@ export const tools: Record = { slack_delete_canvas: slackDeleteCanvasTool, slack_create_conversation: slackCreateConversationTool, slack_invite_to_conversation: slackInviteToConversationTool, + slack_schedule_message: slackScheduleMessageTool, + slack_list_scheduled_messages: slackListScheduledMessagesTool, + slack_delete_scheduled_message: slackDeleteScheduledMessageTool, + slack_archive_conversation: slackArchiveConversationTool, + slack_rename_conversation: slackRenameConversationTool, + slack_set_conversation_topic: slackSetConversationTopicTool, + slack_set_conversation_purpose: slackSetConversationPurposeTool, github_repo_info: githubRepoInfoTool, github_repo_info_v2: githubRepoInfoV2Tool, github_latest_commit: githubLatestCommitTool, @@ -5537,6 +5595,11 @@ export const tools: Record = { monday_move_item_to_group: mondayMoveItemToGroupTool, monday_search_items: mondaySearchItemsTool, monday_update_item: mondayUpdateItemTool, + monday_change_column_value: mondayChangeColumnValueTool, + monday_create_board: mondayCreateBoardTool, + monday_create_column: mondayCreateColumnTool, + monday_duplicate_item: mondayDuplicateItemTool, + monday_get_groups: mondayGetGroupsTool, mongodb_query: mongodbQueryTool, mongodb_insert: mongodbInsertTool, mongodb_update: mongodbUpdateTool, @@ -6090,6 +6153,12 @@ export const tools: Record = { google_docs_insert_image: googleDocsInsertImageTool, google_docs_insert_page_break: googleDocsInsertPageBreakTool, google_docs_update_text_style: googleDocsUpdateTextStyleTool, + google_docs_update_paragraph_style: googleDocsUpdateParagraphStyleTool, + google_docs_create_paragraph_bullets: googleDocsCreateParagraphBulletsTool, + google_docs_delete_paragraph_bullets: googleDocsDeleteParagraphBulletsTool, + google_docs_delete_content_range: googleDocsDeleteContentRangeTool, + google_docs_create_named_range: googleDocsCreateNamedRangeTool, + google_docs_delete_named_range: googleDocsDeleteNamedRangeTool, google_books_volume_search: googleBooksVolumeSearchTool, google_books_volume_details: googleBooksVolumeDetailsTool, google_maps_air_quality: googleMapsAirQualityTool, @@ -6419,6 +6488,13 @@ export const tools: Record = { trello_update_card: trelloUpdateCardTool, trello_get_actions: trelloGetActionsTool, trello_add_comment: trelloAddCommentTool, + trello_create_board: trelloCreateBoardTool, + trello_get_board: trelloGetBoardTool, + trello_create_list: trelloCreateListTool, + trello_get_card: trelloGetCardTool, + trello_add_checklist: trelloAddChecklistTool, + trello_add_label: trelloAddLabelTool, + trello_add_member: trelloAddMemberTool, trigger_dev_trigger_task: triggerDevTriggerTaskTool, trigger_dev_batch_trigger_task: triggerDevBatchTriggerTaskTool, trigger_dev_get_batch: triggerDevGetBatchTool, diff --git a/apps/sim/tools/slack/archive_conversation.ts b/apps/sim/tools/slack/archive_conversation.ts new file mode 100644 index 00000000000..83fa22d5508 --- /dev/null +++ b/apps/sim/tools/slack/archive_conversation.ts @@ -0,0 +1,101 @@ +import type { + SlackArchiveConversationParams, + SlackArchiveConversationResponse, +} from '@/tools/slack/types' +import type { ToolConfig } from '@/tools/types' + +export const slackArchiveConversationTool: ToolConfig< + SlackArchiveConversationParams, + SlackArchiveConversationResponse +> = { + id: 'slack_archive_conversation', + name: 'Slack Archive Conversation', + description: 'Archive a Slack channel so it is closed to new activity.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'slack', + }, + + params: { + authMethod: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication method: oauth or bot_token', + }, + botToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Bot token for Custom Bot', + }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'OAuth access token or bot token for Slack API', + }, + channel: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the channel to archive (e.g., C1234567890)', + }, + }, + + request: { + url: 'https://slack.com/api/conversations.archive', + method: 'POST', + headers: (params: SlackArchiveConversationParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken || params.botToken}`, + }), + body: (params: SlackArchiveConversationParams) => ({ + channel: params.channel?.trim(), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + if (data.error === 'already_archived') { + throw new Error('This channel is already archived.') + } + if (data.error === 'cant_archive_general') { + throw new Error('The #general channel cannot be archived.') + } + if (data.error === 'channel_not_found') { + throw new Error('Channel not found. Please verify the channel ID.') + } + if (data.error === 'not_in_channel') { + throw new Error('The authenticated user is not a member of this channel.') + } + if (data.error === 'missing_scope') { + throw new Error( + 'Missing required permissions. Please reconnect your Slack account with the necessary scopes (channels:manage, groups:write).' + ) + } + if (data.error === 'invalid_auth') { + throw new Error('Invalid authentication. Please check your Slack credentials.') + } + throw new Error(data.error || 'Failed to archive Slack conversation') + } + + return { + success: true, + output: { + ok: true, + }, + } + }, + + outputs: { + ok: { + type: 'boolean', + description: 'Whether the conversation was archived successfully', + }, + }, +} diff --git a/apps/sim/tools/slack/delete_scheduled_message.ts b/apps/sim/tools/slack/delete_scheduled_message.ts new file mode 100644 index 00000000000..efbaadc7cb9 --- /dev/null +++ b/apps/sim/tools/slack/delete_scheduled_message.ts @@ -0,0 +1,104 @@ +import type { + SlackDeleteScheduledMessageParams, + SlackDeleteScheduledMessageResponse, +} from '@/tools/slack/types' +import type { ToolConfig } from '@/tools/types' + +export const slackDeleteScheduledMessageTool: ToolConfig< + SlackDeleteScheduledMessageParams, + SlackDeleteScheduledMessageResponse +> = { + id: 'slack_delete_scheduled_message', + name: 'Slack Delete Scheduled Message', + description: 'Delete a pending scheduled message before it posts to Slack.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'slack', + }, + + params: { + authMethod: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication method: oauth or bot_token', + }, + botToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Bot token for Custom Bot', + }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'OAuth access token or bot token for Slack API', + }, + channel: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Channel ID where the scheduled message is queued (e.g., C1234567890)', + }, + scheduledMessageId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Scheduled message ID from chat.scheduleMessage (e.g., Q1234ABCD)', + }, + }, + + request: { + url: 'https://slack.com/api/chat.deleteScheduledMessage', + method: 'POST', + headers: (params: SlackDeleteScheduledMessageParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken || params.botToken}`, + }), + body: (params: SlackDeleteScheduledMessageParams) => ({ + channel: params.channel?.trim(), + scheduled_message_id: params.scheduledMessageId?.trim(), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + if (data.error === 'invalid_scheduled_message_id') { + throw new Error( + 'Invalid scheduled message ID. The message may have already posted or is set to post within 60 seconds.' + ) + } + if (data.error === 'missing_scope') { + throw new Error( + 'Missing required permissions. Please reconnect your Slack account with the necessary scope (chat:write).' + ) + } + if (data.error === 'channel_not_found') { + throw new Error('Channel not found. Please verify the channel ID.') + } + if (data.error === 'invalid_auth') { + throw new Error('Invalid authentication. Please check your Slack credentials.') + } + throw new Error(data.error || 'Failed to delete scheduled Slack message') + } + + return { + success: true, + output: { + ok: true, + }, + } + }, + + outputs: { + ok: { + type: 'boolean', + description: 'Whether the scheduled message was deleted successfully', + }, + }, +} diff --git a/apps/sim/tools/slack/index.ts b/apps/sim/tools/slack/index.ts index 1612876c998..90c55b42045 100644 --- a/apps/sim/tools/slack/index.ts +++ b/apps/sim/tools/slack/index.ts @@ -1,9 +1,11 @@ import { slackAddReactionTool } from '@/tools/slack/add_reaction' +import { slackArchiveConversationTool } from '@/tools/slack/archive_conversation' import { slackCanvasTool } from '@/tools/slack/canvas' import { slackCreateChannelCanvasTool } from '@/tools/slack/create_channel_canvas' import { slackCreateConversationTool } from '@/tools/slack/create_conversation' import { slackDeleteCanvasTool } from '@/tools/slack/delete_canvas' import { slackDeleteMessageTool } from '@/tools/slack/delete_message' +import { slackDeleteScheduledMessageTool } from '@/tools/slack/delete_scheduled_message' import { slackDownloadTool } from '@/tools/slack/download' import { slackEditCanvasTool } from '@/tools/slack/edit_canvas' import { slackEphemeralMessageTool } from '@/tools/slack/ephemeral_message' @@ -20,6 +22,7 @@ import { slackInviteToConversationTool } from '@/tools/slack/invite_to_conversat import { slackListCanvasesTool } from '@/tools/slack/list_canvases' import { slackListChannelsTool } from '@/tools/slack/list_channels' import { slackListMembersTool } from '@/tools/slack/list_members' +import { slackListScheduledMessagesTool } from '@/tools/slack/list_scheduled_messages' import { slackListUsersTool } from '@/tools/slack/list_users' import { slackLookupCanvasSectionsTool } from '@/tools/slack/lookup_canvas_sections' import { slackMessageTool } from '@/tools/slack/message' @@ -28,6 +31,10 @@ import { slackOpenViewTool } from '@/tools/slack/open_view' import { slackPublishViewTool } from '@/tools/slack/publish_view' import { slackPushViewTool } from '@/tools/slack/push_view' import { slackRemoveReactionTool } from '@/tools/slack/remove_reaction' +import { slackRenameConversationTool } from '@/tools/slack/rename_conversation' +import { slackScheduleMessageTool } from '@/tools/slack/schedule_message' +import { slackSetConversationPurposeTool } from '@/tools/slack/set_conversation_purpose' +import { slackSetConversationTopicTool } from '@/tools/slack/set_conversation_topic' import { slackSetStatusTool } from '@/tools/slack/set_status' import { slackSetSuggestedPromptsTool } from '@/tools/slack/set_suggested_prompts' import { slackSetTitleTool } from '@/tools/slack/set_title' @@ -70,6 +77,13 @@ export { slackSetTitleTool, slackSetSuggestedPromptsTool, slackInviteToConversationTool, + slackScheduleMessageTool, + slackListScheduledMessagesTool, + slackDeleteScheduledMessageTool, + slackArchiveConversationTool, + slackRenameConversationTool, + slackSetConversationTopicTool, + slackSetConversationPurposeTool, } export * from './types' diff --git a/apps/sim/tools/slack/list_scheduled_messages.ts b/apps/sim/tools/slack/list_scheduled_messages.ts new file mode 100644 index 00000000000..81fb8d04529 --- /dev/null +++ b/apps/sim/tools/slack/list_scheduled_messages.ts @@ -0,0 +1,145 @@ +import type { + SlackListScheduledMessagesParams, + SlackListScheduledMessagesResponse, +} from '@/tools/slack/types' +import { SCHEDULED_MESSAGE_OUTPUT_PROPERTIES } from '@/tools/slack/types' +import type { ToolConfig } from '@/tools/types' + +export const slackListScheduledMessagesTool: ToolConfig< + SlackListScheduledMessagesParams, + SlackListScheduledMessagesResponse +> = { + id: 'slack_list_scheduled_messages', + name: 'Slack List Scheduled Messages', + description: + 'List pending scheduled messages in a Slack workspace, optionally filtered by channel.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'slack', + }, + + params: { + authMethod: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication method: oauth or bot_token', + }, + botToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Bot token for Custom Bot', + }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'OAuth access token or bot token for Slack API', + }, + channel: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional channel ID to filter scheduled messages (e.g., C1234567890)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of scheduled messages to return', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor (next_cursor) from a previous response', + }, + oldest: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Unix timestamp of the oldest scheduled message to include', + }, + latest: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Unix timestamp of the latest scheduled message to include', + }, + teamId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Encoded team ID (required only with org-level tokens)', + }, + }, + + request: { + url: 'https://slack.com/api/chat.scheduledMessages.list', + method: 'POST', + headers: (params: SlackListScheduledMessagesParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken || params.botToken}`, + }), + body: (params: SlackListScheduledMessagesParams) => { + const body: Record = {} + if (params.channel?.trim()) { + body.channel = params.channel.trim() + } + if (params.limit != null) { + body.limit = params.limit + } + if (params.cursor?.trim()) { + body.cursor = params.cursor.trim() + } + if (params.oldest?.trim()) { + body.oldest = params.oldest.trim() + } + if (params.latest?.trim()) { + body.latest = params.latest.trim() + } + if (params.teamId?.trim()) { + body.team_id = params.teamId.trim() + } + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + if (data.error === 'invalid_auth') { + throw new Error('Invalid authentication. Please check your Slack credentials.') + } + throw new Error(data.error || 'Failed to list scheduled Slack messages') + } + + return { + success: true, + output: { + scheduledMessages: data.scheduled_messages || [], + nextCursor: data.response_metadata?.next_cursor || null, + }, + } + }, + + outputs: { + scheduledMessages: { + type: 'array', + description: 'Array of pending scheduled message objects', + items: { + type: 'object', + properties: SCHEDULED_MESSAGE_OUTPUT_PROPERTIES, + }, + }, + nextCursor: { + type: 'string', + description: 'Cursor for the next page (null when there are no more pages)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/slack/rename_conversation.ts b/apps/sim/tools/slack/rename_conversation.ts new file mode 100644 index 00000000000..8244ca0ae97 --- /dev/null +++ b/apps/sim/tools/slack/rename_conversation.ts @@ -0,0 +1,133 @@ +import type { + SlackRenameConversationParams, + SlackRenameConversationResponse, +} from '@/tools/slack/types' +import { CHANNEL_OUTPUT_PROPERTIES } from '@/tools/slack/types' +import type { ToolConfig } from '@/tools/types' + +export const slackRenameConversationTool: ToolConfig< + SlackRenameConversationParams, + SlackRenameConversationResponse +> = { + id: 'slack_rename_conversation', + name: 'Slack Rename Conversation', + description: 'Rename an existing Slack channel.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'slack', + }, + + params: { + authMethod: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication method: oauth or bot_token', + }, + botToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Bot token for Custom Bot', + }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'OAuth access token or bot token for Slack API', + }, + channel: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the channel to rename (e.g., C1234567890)', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'New channel name (lowercase letters, numbers, hyphens, underscores only; max 80 characters)', + }, + }, + + request: { + url: 'https://slack.com/api/conversations.rename', + method: 'POST', + headers: (params: SlackRenameConversationParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken || params.botToken}`, + }), + body: (params: SlackRenameConversationParams) => ({ + channel: params.channel?.trim(), + name: params.name?.trim(), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + if (data.error === 'name_taken') { + throw new Error('A channel with this name already exists in the workspace.') + } + if ( + data.error === 'invalid_name' || + data.error === 'invalid_name_specials' || + data.error === 'invalid_name_maxlength' || + data.error === 'invalid_name_required' + ) { + throw new Error( + 'Invalid channel name. Use only lowercase letters, numbers, hyphens, and underscores (max 80 characters).' + ) + } + if (data.error === 'channel_not_found') { + throw new Error('Channel not found. Please verify the channel ID.') + } + if (data.error === 'not_in_channel') { + throw new Error('The authenticated user is not a member of this channel.') + } + if (data.error === 'not_authorized') { + throw new Error('You do not have permission to rename this channel.') + } + if (data.error === 'missing_scope') { + throw new Error( + 'Missing required permissions. Please reconnect your Slack account with the necessary scopes (channels:manage, groups:write).' + ) + } + if (data.error === 'invalid_auth') { + throw new Error('Invalid authentication. Please check your Slack credentials.') + } + throw new Error(data.error || 'Failed to rename Slack conversation') + } + + const ch = data.channel || {} + + return { + success: true, + output: { + channelInfo: { + id: ch.id, + name: ch.name, + is_private: ch.is_private || false, + is_archived: ch.is_archived || false, + is_member: ch.is_member || false, + topic: ch.topic?.value || '', + purpose: ch.purpose?.value || '', + created: ch.created, + creator: ch.creator, + }, + }, + } + }, + + outputs: { + channelInfo: { + type: 'object', + description: 'The channel object after renaming', + properties: CHANNEL_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/slack/schedule_message.ts b/apps/sim/tools/slack/schedule_message.ts new file mode 100644 index 00000000000..31075e879b9 --- /dev/null +++ b/apps/sim/tools/slack/schedule_message.ts @@ -0,0 +1,138 @@ +import type { SlackScheduleMessageParams, SlackScheduleMessageResponse } from '@/tools/slack/types' +import type { ToolConfig } from '@/tools/types' + +export const slackScheduleMessageTool: ToolConfig< + SlackScheduleMessageParams, + SlackScheduleMessageResponse +> = { + id: 'slack_schedule_message', + name: 'Slack Schedule Message', + description: 'Schedule a message to be sent to a Slack channel or DM at a future time.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'slack', + }, + + params: { + authMethod: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication method: oauth or bot_token', + }, + botToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Bot token for Custom Bot', + }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'OAuth access token or bot token for Slack API', + }, + channel: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Channel, private group, or DM to receive the message (e.g., C1234567890)', + }, + postAt: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unix timestamp (seconds) representing the future time the message should post', + }, + text: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Message text to send (supports Slack mrkdwn formatting)', + }, + blocks: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Block Kit layout blocks as a JSON array. When provided, text becomes the fallback notification text.', + }, + threadTs: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Thread timestamp to reply to (creates a scheduled thread reply)', + }, + }, + + request: { + url: 'https://slack.com/api/chat.scheduleMessage', + method: 'POST', + headers: (params: SlackScheduleMessageParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken || params.botToken}`, + }), + body: (params: SlackScheduleMessageParams) => { + const body: Record = { + channel: params.channel?.trim(), + post_at: params.postAt, + } + if (params.text) { + body.text = params.text + } + if (params.blocks) { + body.blocks = typeof params.blocks === 'string' ? JSON.parse(params.blocks) : params.blocks + } + if (params.threadTs?.trim()) { + body.thread_ts = params.threadTs.trim() + } + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + if (data.error === 'time_in_past' || data.error === 'time_too_far') { + throw new Error( + 'The scheduled time is invalid. It must be in the future and within 120 days.' + ) + } + if (data.error === 'missing_scope') { + throw new Error( + 'Missing required permissions. Please reconnect your Slack account with the necessary scope (chat:write).' + ) + } + if (data.error === 'channel_not_found') { + throw new Error('Channel not found. Please verify the channel ID.') + } + if (data.error === 'invalid_auth') { + throw new Error('Invalid authentication. Please check your Slack credentials.') + } + throw new Error(data.error || 'Failed to schedule Slack message') + } + + return { + success: true, + output: { + scheduledMessageId: data.scheduled_message_id, + postAt: data.post_at, + channel: data.channel, + message: data.message || {}, + }, + } + }, + + outputs: { + scheduledMessageId: { + type: 'string', + description: 'Identifier of the scheduled message (used to delete it before it posts)', + }, + postAt: { type: 'number', description: 'Unix timestamp when the message will post' }, + channel: { type: 'string', description: 'Channel ID where the message is scheduled' }, + message: { type: 'object', description: 'The scheduled message object returned by Slack' }, + }, +} diff --git a/apps/sim/tools/slack/set_conversation_purpose.ts b/apps/sim/tools/slack/set_conversation_purpose.ts new file mode 100644 index 00000000000..bad99f8c209 --- /dev/null +++ b/apps/sim/tools/slack/set_conversation_purpose.ts @@ -0,0 +1,105 @@ +import type { + SlackSetConversationPurposeParams, + SlackSetConversationPurposeResponse, +} from '@/tools/slack/types' +import type { ToolConfig } from '@/tools/types' + +export const slackSetConversationPurposeTool: ToolConfig< + SlackSetConversationPurposeParams, + SlackSetConversationPurposeResponse +> = { + id: 'slack_set_conversation_purpose', + name: 'Slack Set Conversation Purpose', + description: 'Set the purpose (description) for a Slack channel (max 250 characters).', + version: '1.0.0', + + oauth: { + required: true, + provider: 'slack', + }, + + params: { + authMethod: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication method: oauth or bot_token', + }, + botToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Bot token for Custom Bot', + }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'OAuth access token or bot token for Slack API', + }, + channel: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the channel to update (e.g., C1234567890)', + }, + purpose: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'New purpose/description text (max 250 characters)', + }, + }, + + request: { + url: 'https://slack.com/api/conversations.setPurpose', + method: 'POST', + headers: (params: SlackSetConversationPurposeParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken || params.botToken}`, + }), + body: (params: SlackSetConversationPurposeParams) => ({ + channel: params.channel?.trim(), + purpose: params.purpose, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + if (data.error === 'too_long') { + throw new Error('The purpose is too long. The maximum length is 250 characters.') + } + if (data.error === 'channel_not_found') { + throw new Error('Channel not found. Please verify the channel ID.') + } + if (data.error === 'not_in_channel') { + throw new Error('The authenticated user is not a member of this channel.') + } + if (data.error === 'missing_scope') { + throw new Error( + 'Missing required permissions. Please reconnect your Slack account with the necessary scopes (channels:manage, groups:write).' + ) + } + if (data.error === 'invalid_auth') { + throw new Error('Invalid authentication. Please check your Slack credentials.') + } + throw new Error(data.error || 'Failed to set Slack conversation purpose') + } + + return { + success: true, + output: { + purpose: data.purpose ?? '', + }, + } + }, + + outputs: { + purpose: { + type: 'string', + description: 'The purpose/description that was set on the channel', + }, + }, +} diff --git a/apps/sim/tools/slack/set_conversation_topic.ts b/apps/sim/tools/slack/set_conversation_topic.ts new file mode 100644 index 00000000000..693de3c329e --- /dev/null +++ b/apps/sim/tools/slack/set_conversation_topic.ts @@ -0,0 +1,119 @@ +import type { + SlackSetConversationTopicParams, + SlackSetConversationTopicResponse, +} from '@/tools/slack/types' +import { CHANNEL_OUTPUT_PROPERTIES } from '@/tools/slack/types' +import type { ToolConfig } from '@/tools/types' + +export const slackSetConversationTopicTool: ToolConfig< + SlackSetConversationTopicParams, + SlackSetConversationTopicResponse +> = { + id: 'slack_set_conversation_topic', + name: 'Slack Set Conversation Topic', + description: 'Set the topic for a Slack channel (max 250 characters).', + version: '1.0.0', + + oauth: { + required: true, + provider: 'slack', + }, + + params: { + authMethod: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication method: oauth or bot_token', + }, + botToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Bot token for Custom Bot', + }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'OAuth access token or bot token for Slack API', + }, + channel: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the channel to update (e.g., C1234567890)', + }, + topic: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'New topic text (max 250 characters; no formatting or linkification)', + }, + }, + + request: { + url: 'https://slack.com/api/conversations.setTopic', + method: 'POST', + headers: (params: SlackSetConversationTopicParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken || params.botToken}`, + }), + body: (params: SlackSetConversationTopicParams) => ({ + channel: params.channel?.trim(), + topic: params.topic, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + if (data.error === 'too_long') { + throw new Error('The topic is too long. The maximum length is 250 characters.') + } + if (data.error === 'channel_not_found') { + throw new Error('Channel not found. Please verify the channel ID.') + } + if (data.error === 'not_in_channel') { + throw new Error('The authenticated user is not a member of this channel.') + } + if (data.error === 'missing_scope') { + throw new Error( + 'Missing required permissions. Please reconnect your Slack account with the necessary scopes (channels:manage, groups:write).' + ) + } + if (data.error === 'invalid_auth') { + throw new Error('Invalid authentication. Please check your Slack credentials.') + } + throw new Error(data.error || 'Failed to set Slack conversation topic') + } + + const ch = data.channel || {} + + return { + success: true, + output: { + channelInfo: { + id: ch.id, + name: ch.name, + is_private: ch.is_private || false, + is_archived: ch.is_archived || false, + is_member: ch.is_member || false, + topic: ch.topic?.value || '', + purpose: ch.purpose?.value || '', + created: ch.created, + creator: ch.creator, + }, + }, + } + }, + + outputs: { + channelInfo: { + type: 'object', + description: 'The channel object after updating the topic', + properties: CHANNEL_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/slack/types.ts b/apps/sim/tools/slack/types.ts index 552b93a3452..3489109202d 100644 --- a/apps/sim/tools/slack/types.ts +++ b/apps/sim/tools/slack/types.ts @@ -302,6 +302,18 @@ export const CHANNEL_OUTPUT_PROPERTIES = { updated: { type: 'number', description: 'Unix timestamp of last update', optional: true }, } as const satisfies Record +/** + * Output definition for scheduled message objects + * Based on Slack chat.scheduledMessages.list (https://docs.slack.dev/reference/methods/chat.scheduledMessages.list) + */ +export const SCHEDULED_MESSAGE_OUTPUT_PROPERTIES = { + id: { type: 'string', description: 'Scheduled message ID' }, + channel_id: { type: 'string', description: 'Channel the message is scheduled for' }, + post_at: { type: 'number', description: 'Unix timestamp when the message will post' }, + date_created: { type: 'number', description: 'Unix timestamp when the schedule was created' }, + text: { type: 'string', description: 'Scheduled message text', optional: true }, +} as const satisfies Record + /** * Complete channel object output definition */ @@ -913,6 +925,47 @@ export interface SlackPublishViewParams extends SlackBaseParams { view: object | string } +export interface SlackScheduleMessageParams extends SlackBaseParams { + channel: string + postAt: number + text?: string + blocks?: string + threadTs?: string +} + +export interface SlackListScheduledMessagesParams extends SlackBaseParams { + channel?: string + limit?: number + cursor?: string + oldest?: string + latest?: string + teamId?: string +} + +export interface SlackDeleteScheduledMessageParams extends SlackBaseParams { + channel: string + scheduledMessageId: string +} + +export interface SlackArchiveConversationParams extends SlackBaseParams { + channel: string +} + +export interface SlackRenameConversationParams extends SlackBaseParams { + channel: string + name: string +} + +export interface SlackSetConversationTopicParams extends SlackBaseParams { + channel: string + topic: string +} + +export interface SlackSetConversationPurposeParams extends SlackBaseParams { + channel: string + purpose: string +} + export interface SlackMessageResponse extends ToolResponse { output: { // Legacy properties for backward compatibility @@ -1398,6 +1451,60 @@ export interface SlackGetThreadRepliesResponse extends ToolResponse { } } +export interface SlackScheduledMessage { + id: string + channel_id: string + post_at: number + date_created: number + text?: string +} + +export interface SlackScheduleMessageResponse extends ToolResponse { + output: { + scheduledMessageId: string + postAt: number + channel: string + message: Record + } +} + +export interface SlackListScheduledMessagesResponse extends ToolResponse { + output: { + scheduledMessages: SlackScheduledMessage[] + nextCursor: string | null + } +} + +export interface SlackDeleteScheduledMessageResponse extends ToolResponse { + output: { + ok: boolean + } +} + +export interface SlackArchiveConversationResponse extends ToolResponse { + output: { + ok: boolean + } +} + +export interface SlackRenameConversationResponse extends ToolResponse { + output: { + channelInfo: SlackChannel + } +} + +export interface SlackSetConversationTopicResponse extends ToolResponse { + output: { + channelInfo: SlackChannel + } +} + +export interface SlackSetConversationPurposeResponse extends ToolResponse { + output: { + purpose: string + } +} + export type SlackResponse = | SlackCanvasResponse | SlackMessageReaderResponse @@ -1434,3 +1541,10 @@ export type SlackResponse = | SlackUpdateViewResponse | SlackPushViewResponse | SlackPublishViewResponse + | SlackScheduleMessageResponse + | SlackListScheduledMessagesResponse + | SlackDeleteScheduledMessageResponse + | SlackArchiveConversationResponse + | SlackRenameConversationResponse + | SlackSetConversationTopicResponse + | SlackSetConversationPurposeResponse diff --git a/apps/sim/tools/trello/add_checklist.ts b/apps/sim/tools/trello/add_checklist.ts new file mode 100644 index 00000000000..bb4583505ec --- /dev/null +++ b/apps/sim/tools/trello/add_checklist.ts @@ -0,0 +1,136 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloChecklist, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' +import type { TrelloAddChecklistParams, TrelloAddChecklistResponse } from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloAddChecklistTool: ToolConfig< + TrelloAddChecklistParams, + TrelloAddChecklistResponse +> = { + id: 'trello_add_checklist', + name: 'Trello Add Checklist', + description: 'Add a checklist to a Trello card', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + cardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello card ID to add the checklist to (24-character hex string)', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the checklist', + }, + pos: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Position of the checklist (top, bottom, or positive float)', + }, + }, + + request: { + url: (params) => { + if (!params.cardId) { + throw new Error('Card ID is required') + } + if (!params.name) { + throw new Error('Checklist name is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}/checklists`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + url.searchParams.set('name', params.name.trim()) + + if (params.pos) url.searchParams.set('pos', params.pos) + + return url.toString() + }, + method: 'POST', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to add checklist') + + return { + success: false, + output: { + error, + }, + error, + } + } + + try { + const checklist = mapTrelloChecklist(data) + + return { + success: true, + output: { + checklist, + }, + } + } catch (error) { + const message = getErrorMessage(error, 'Failed to parse created checklist') + + return { + success: false, + output: { + error: message, + }, + error: message, + } + } + }, + + outputs: { + checklist: { + type: 'json', + description: 'Created checklist (id, name, idCard, idBoard, pos)', + optional: true, + properties: { + id: { type: 'string', description: 'Checklist ID' }, + name: { type: 'string', description: 'Checklist name' }, + idCard: { type: 'string', description: 'Card ID containing the checklist' }, + idBoard: { + type: 'string', + description: 'Board ID containing the checklist', + optional: true, + }, + pos: { type: 'number', description: 'Checklist position on the card' }, + }, + }, + }, +} diff --git a/apps/sim/tools/trello/add_label.ts b/apps/sim/tools/trello/add_label.ts new file mode 100644 index 00000000000..24773b9b657 --- /dev/null +++ b/apps/sim/tools/trello/add_label.ts @@ -0,0 +1,99 @@ +import { env } from '@/lib/core/config/env' +import { extractTrelloErrorMessage, getIdArray, TRELLO_API_BASE_URL } from '@/tools/trello/shared' +import type { TrelloAddLabelParams, TrelloAddLabelResponse } from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloAddLabelTool: ToolConfig = { + id: 'trello_add_label', + name: 'Trello Add Label', + description: 'Attach an existing label to a Trello card', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + cardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello card ID to attach the label to (24-character hex string)', + }, + labelId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the label to attach (24-character hex string)', + }, + }, + + request: { + url: (params) => { + if (!params.cardId) { + throw new Error('Card ID is required') + } + if (!params.labelId) { + throw new Error('Label ID is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}/idLabels`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + url.searchParams.set('value', params.labelId.trim()) + + return url.toString() + }, + method: 'POST', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to add label') + + return { + success: false, + output: { + labelIds: [], + error, + }, + error, + } + } + + return { + success: true, + output: { + labelIds: getIdArray(data), + }, + } + }, + + outputs: { + labelIds: { + type: 'array', + description: 'Label IDs now applied to the card', + items: { + type: 'string', + description: 'A Trello label ID', + }, + }, + }, +} diff --git a/apps/sim/tools/trello/add_member.ts b/apps/sim/tools/trello/add_member.ts new file mode 100644 index 00000000000..1baa8df446e --- /dev/null +++ b/apps/sim/tools/trello/add_member.ts @@ -0,0 +1,99 @@ +import { env } from '@/lib/core/config/env' +import { extractTrelloErrorMessage, getIdArray, TRELLO_API_BASE_URL } from '@/tools/trello/shared' +import type { TrelloAddMemberParams, TrelloAddMemberResponse } from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloAddMemberTool: ToolConfig = { + id: 'trello_add_member', + name: 'Trello Add Member', + description: 'Assign a member to a Trello card', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + cardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello card ID to assign the member to (24-character hex string)', + }, + memberId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the member to assign (24-character hex string)', + }, + }, + + request: { + url: (params) => { + if (!params.cardId) { + throw new Error('Card ID is required') + } + if (!params.memberId) { + throw new Error('Member ID is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}/idMembers`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + url.searchParams.set('value', params.memberId.trim()) + + return url.toString() + }, + method: 'POST', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to add member') + + return { + success: false, + output: { + memberIds: [], + error, + }, + error, + } + } + + return { + success: true, + output: { + memberIds: getIdArray(data), + }, + } + }, + + outputs: { + memberIds: { + type: 'array', + description: 'Member IDs now assigned to the card', + items: { + type: 'string', + description: 'A Trello member ID', + }, + }, + }, +} diff --git a/apps/sim/tools/trello/create_board.ts b/apps/sim/tools/trello/create_board.ts new file mode 100644 index 00000000000..5c776a2d342 --- /dev/null +++ b/apps/sim/tools/trello/create_board.ts @@ -0,0 +1,143 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloBoard, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' +import type { TrelloCreateBoardParams, TrelloCreateBoardResponse } from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloCreateBoardTool: ToolConfig = + { + id: 'trello_create_board', + name: 'Trello Create Board', + description: 'Create a new Trello board', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the board', + }, + desc: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the board', + }, + idOrganization: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ID or name of the workspace/organization the board belongs to', + }, + defaultLists: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to create the default lists (To Do, Doing, Done) on the new board', + }, + }, + + request: { + url: (params) => { + if (!params.name) { + throw new Error('Board name is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/boards`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + url.searchParams.set('name', params.name.trim()) + + if (params.desc) url.searchParams.set('desc', params.desc) + if (params.idOrganization) + url.searchParams.set('idOrganization', params.idOrganization.trim()) + if (params.defaultLists !== undefined) { + url.searchParams.set('defaultLists', String(params.defaultLists)) + } + + return url.toString() + }, + method: 'POST', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to create board') + + return { + success: false, + output: { + error, + }, + error, + } + } + + try { + const board = mapTrelloBoard(data) + + return { + success: true, + output: { + board, + }, + } + } catch (error) { + const message = getErrorMessage(error, 'Failed to parse created board') + + return { + success: false, + output: { + error: message, + }, + error: message, + } + } + }, + + outputs: { + board: { + type: 'json', + description: 'Created board (id, name, desc, url, closed, idOrganization)', + optional: true, + properties: { + id: { type: 'string', description: 'Board ID' }, + name: { type: 'string', description: 'Board name' }, + desc: { type: 'string', description: 'Board description' }, + url: { type: 'string', description: 'Full board URL' }, + closed: { type: 'boolean', description: 'Whether the board is closed' }, + idOrganization: { + type: 'string', + description: 'ID of the workspace/organization the board belongs to', + optional: true, + }, + }, + }, + }, + } diff --git a/apps/sim/tools/trello/create_list.ts b/apps/sim/tools/trello/create_list.ts new file mode 100644 index 00000000000..c89c271472a --- /dev/null +++ b/apps/sim/tools/trello/create_list.ts @@ -0,0 +1,130 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloList, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' +import type { TrelloCreateListParams, TrelloCreateListResponse } from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloCreateListTool: ToolConfig = { + id: 'trello_create_list', + name: 'Trello Create List', + description: 'Create a new list on a Trello board', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + boardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello board ID the list belongs to (24-character hex string)', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the list', + }, + pos: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Position of the list (top, bottom, or positive float)', + }, + }, + + request: { + url: (params) => { + if (!params.name) { + throw new Error('List name is required') + } + if (!params.boardId) { + throw new Error('Board ID is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/lists`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + url.searchParams.set('name', params.name.trim()) + url.searchParams.set('idBoard', params.boardId.trim()) + + if (params.pos) url.searchParams.set('pos', params.pos) + + return url.toString() + }, + method: 'POST', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to create list') + + return { + success: false, + output: { + error, + }, + error, + } + } + + try { + const list = mapTrelloList(data) + + return { + success: true, + output: { + list, + }, + } + } catch (error) { + const message = getErrorMessage(error, 'Failed to parse created list') + + return { + success: false, + output: { + error: message, + }, + error: message, + } + } + }, + + outputs: { + list: { + type: 'json', + description: 'Created list (id, name, closed, pos, idBoard)', + optional: true, + properties: { + id: { type: 'string', description: 'List ID' }, + name: { type: 'string', description: 'List name' }, + closed: { type: 'boolean', description: 'Whether the list is archived' }, + pos: { type: 'number', description: 'List position on the board' }, + idBoard: { type: 'string', description: 'Board ID containing the list' }, + }, + }, + }, +} diff --git a/apps/sim/tools/trello/get_actions.ts b/apps/sim/tools/trello/get_actions.ts index f660f289587..00857a5bede 100644 --- a/apps/sim/tools/trello/get_actions.ts +++ b/apps/sim/tools/trello/get_actions.ts @@ -84,7 +84,7 @@ export const trelloGetActionsTool: ToolConfig = { + id: 'trello_get_board', + name: 'Trello Get Board', + description: 'Retrieve a single Trello board by ID', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + boardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello board ID (24-character hex string)', + }, + }, + + request: { + url: (params) => { + if (!params.boardId) { + throw new Error('Board ID is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/boards/${params.boardId.trim()}`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() + }, + method: 'GET', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to get board') + + return { + success: false, + output: { + error, + }, + error, + } + } + + try { + const board = mapTrelloBoard(data) + + return { + success: true, + output: { + board, + }, + } + } catch (error) { + const message = getErrorMessage(error, 'Failed to parse board') + + return { + success: false, + output: { + error: message, + }, + error: message, + } + } + }, + + outputs: { + board: { + type: 'json', + description: 'Board (id, name, desc, url, closed, idOrganization)', + optional: true, + properties: { + id: { type: 'string', description: 'Board ID' }, + name: { type: 'string', description: 'Board name' }, + desc: { type: 'string', description: 'Board description' }, + url: { type: 'string', description: 'Full board URL' }, + closed: { type: 'boolean', description: 'Whether the board is closed' }, + idOrganization: { + type: 'string', + description: 'ID of the workspace/organization the board belongs to', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/trello/get_card.ts b/apps/sim/tools/trello/get_card.ts new file mode 100644 index 00000000000..33935104ed8 --- /dev/null +++ b/apps/sim/tools/trello/get_card.ts @@ -0,0 +1,148 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloCard, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' +import type { TrelloGetCardParams, TrelloGetCardResponse } from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloGetCardTool: ToolConfig = { + id: 'trello_get_card', + name: 'Trello Get Card', + description: 'Retrieve a single Trello card by ID', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + cardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello card ID (24-character hex string)', + }, + }, + + request: { + url: (params) => { + if (!params.cardId) { + throw new Error('Card ID is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() + }, + method: 'GET', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to get card') + + return { + success: false, + output: { + error, + }, + error, + } + } + + try { + const card = mapTrelloCard(data) + + return { + success: true, + output: { + card, + }, + } + } catch (error) { + const message = getErrorMessage(error, 'Failed to parse card') + + return { + success: false, + output: { + error: message, + }, + error: message, + } + } + }, + + outputs: { + card: { + type: 'json', + description: + 'Card (id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete)', + optional: true, + properties: { + id: { type: 'string', description: 'Card ID' }, + name: { type: 'string', description: 'Card name' }, + desc: { type: 'string', description: 'Card description' }, + url: { type: 'string', description: 'Full card URL' }, + idBoard: { type: 'string', description: 'Board ID containing the card' }, + idList: { type: 'string', description: 'List ID containing the card' }, + closed: { type: 'boolean', description: 'Whether the card is archived' }, + labelIds: { + type: 'array', + description: 'Label IDs applied to the card', + items: { + type: 'string', + description: 'A Trello label ID', + }, + }, + labels: { + type: 'array', + description: 'Labels applied to the card', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Label ID' }, + name: { type: 'string', description: 'Label name' }, + color: { + type: 'string', + description: 'Label color', + optional: true, + }, + }, + }, + }, + due: { + type: 'string', + description: 'Card due date in ISO 8601 format', + optional: true, + }, + dueComplete: { + type: 'boolean', + description: 'Whether the due date is complete', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/trello/index.ts b/apps/sim/tools/trello/index.ts index e420abf3893..9f25bc4659f 100644 --- a/apps/sim/tools/trello/index.ts +++ b/apps/sim/tools/trello/index.ts @@ -1,6 +1,13 @@ +import { trelloAddChecklistTool } from '@/tools/trello/add_checklist' import { trelloAddCommentTool } from '@/tools/trello/add_comment' +import { trelloAddLabelTool } from '@/tools/trello/add_label' +import { trelloAddMemberTool } from '@/tools/trello/add_member' +import { trelloCreateBoardTool } from '@/tools/trello/create_board' import { trelloCreateCardTool } from '@/tools/trello/create_card' +import { trelloCreateListTool } from '@/tools/trello/create_list' import { trelloGetActionsTool } from '@/tools/trello/get_actions' +import { trelloGetBoardTool } from '@/tools/trello/get_board' +import { trelloGetCardTool } from '@/tools/trello/get_card' import { trelloListCardsTool } from '@/tools/trello/list_cards' import { trelloListListsTool } from '@/tools/trello/list_lists' import { trelloUpdateCardTool } from '@/tools/trello/update_card' @@ -12,6 +19,13 @@ export { trelloUpdateCardTool, trelloGetActionsTool, trelloAddCommentTool, + trelloCreateBoardTool, + trelloGetBoardTool, + trelloCreateListTool, + trelloGetCardTool, + trelloAddChecklistTool, + trelloAddLabelTool, + trelloAddMemberTool, } export * from '@/tools/trello/types' diff --git a/apps/sim/tools/trello/shared.ts b/apps/sim/tools/trello/shared.ts index 2491d5add68..27617a5ca77 100644 --- a/apps/sim/tools/trello/shared.ts +++ b/apps/sim/tools/trello/shared.ts @@ -4,7 +4,9 @@ import type { TrelloActionBoardTarget, TrelloActionCardTarget, TrelloActionListTarget, + TrelloBoard, TrelloCard, + TrelloChecklist, TrelloComment, TrelloLabel, TrelloList, @@ -52,7 +54,7 @@ function getOptionalNumber(value: unknown): number | null { return null } -function getIdArray(value: unknown): string[] { +export function getIdArray(value: unknown): string[] { if (!Array.isArray(value)) { return [] } @@ -175,6 +177,35 @@ export function mapTrelloCard(value: unknown): TrelloCard { } } +export function mapTrelloBoard(value: unknown): TrelloBoard { + if (!isRecordLike(value)) { + throw new Error('Trello returned an invalid board object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getRequiredString(value.name, 'name'), + desc: typeof value.desc === 'string' ? value.desc : '', + url: getRequiredString(value.url, 'url'), + closed: typeof value.closed === 'boolean' ? value.closed : false, + idOrganization: getOptionalString(value.idOrganization), + } +} + +export function mapTrelloChecklist(value: unknown): TrelloChecklist { + if (!isRecordLike(value)) { + throw new Error('Trello returned an invalid checklist object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getRequiredString(value.name, 'name'), + idCard: getRequiredString(value.idCard, 'idCard'), + idBoard: getOptionalString(value.idBoard), + pos: getNumber(value.pos), + } +} + export function mapTrelloAction(value: unknown): TrelloAction { if (!isRecordLike(value)) { throw new Error('Trello returned an invalid action object') diff --git a/apps/sim/tools/trello/types.ts b/apps/sim/tools/trello/types.ts index e3edc3c592a..fdbbbc8d7e3 100644 --- a/apps/sim/tools/trello/types.ts +++ b/apps/sim/tools/trello/types.ts @@ -1,11 +1,20 @@ import type { ToolResponse } from '@/tools/types' -interface TrelloBoard { +export interface TrelloBoard { id: string name: string desc: string url: string closed: boolean + idOrganization: string | null +} + +export interface TrelloChecklist { + id: string + name: string + idCard: string + idBoard: string | null + pos: number } export interface TrelloLabel { @@ -123,6 +132,50 @@ export interface TrelloAddCommentParams { text: string } +export interface TrelloCreateBoardParams { + accessToken: string + name: string + desc?: string + idOrganization?: string + defaultLists?: boolean +} + +export interface TrelloGetBoardParams { + accessToken: string + boardId: string +} + +export interface TrelloCreateListParams { + accessToken: string + boardId: string + name: string + pos?: string +} + +export interface TrelloGetCardParams { + accessToken: string + cardId: string +} + +export interface TrelloAddChecklistParams { + accessToken: string + cardId: string + name: string + pos?: string +} + +export interface TrelloAddLabelParams { + accessToken: string + cardId: string + labelId: string +} + +export interface TrelloAddMemberParams { + accessToken: string + cardId: string + memberId: string +} + export interface TrelloListListsResponse extends ToolResponse { output: { lists: TrelloList[] @@ -168,6 +221,55 @@ export interface TrelloAddCommentResponse extends ToolResponse { } } +export interface TrelloCreateBoardResponse extends ToolResponse { + output: { + board?: TrelloBoard + error?: string + } +} + +export interface TrelloGetBoardResponse extends ToolResponse { + output: { + board?: TrelloBoard + error?: string + } +} + +export interface TrelloCreateListResponse extends ToolResponse { + output: { + list?: TrelloList + error?: string + } +} + +export interface TrelloGetCardResponse extends ToolResponse { + output: { + card?: TrelloCard + error?: string + } +} + +export interface TrelloAddChecklistResponse extends ToolResponse { + output: { + checklist?: TrelloChecklist + error?: string + } +} + +export interface TrelloAddLabelResponse extends ToolResponse { + output: { + labelIds: string[] + error?: string + } +} + +export interface TrelloAddMemberResponse extends ToolResponse { + output: { + memberIds: string[] + error?: string + } +} + export type TrelloResponse = | TrelloListListsResponse | TrelloListCardsResponse @@ -175,3 +277,10 @@ export type TrelloResponse = | TrelloUpdateCardResponse | TrelloGetActionsResponse | TrelloAddCommentResponse + | TrelloCreateBoardResponse + | TrelloGetBoardResponse + | TrelloCreateListResponse + | TrelloGetCardResponse + | TrelloAddChecklistResponse + | TrelloAddLabelResponse + | TrelloAddMemberResponse diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 5387f3cf638..48fc2560c09 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 873, - zodRoutes: 873, + totalRoutes: 881, + zodRoutes: 881, nonZodRoutes: 0, } as const From 8bf1989060d534339e7b5e506068e44c0986ec03 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 30 Jun 2026 11:53:00 -0700 Subject: [PATCH 05/27] feat(providers): add Claude Sonnet 5 model (#5291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(providers): add Claude Sonnet 5 model - Add claude-sonnet-5 (1M context, 128k output, adaptive thinking with low/medium/high/xhigh/max effort) as the new flagship Anthropic Sonnet - Use introductory pricing ($2/$10 per MTok, $0.20 cached) in effect through Aug 31 2026 - Promote to recommended and set as Anthropic defaultModel; demote claude-sonnet-4-6 - Route claude-sonnet-5 through adaptive thinking in anthropic/core.ts (manual budget_tokens returns a 400) * fix(providers): expose temperature capability on claude-sonnet-5 Sonnet 5 accepts the temperature parameter (0-1, per Anthropic API docs), and it replaces claude-sonnet-4-6 as the default/recommended Anthropic model. Omitting the capability silently dropped temperature control for workflows on the default model. The request builder already strips temperature whenever thinking is active, so this only takes effect when thinking is disabled — where temperature is valid. * feat(blocks): default agent-family blocks to claude-sonnet-5 Propagate the Sonnet 5 flagship promotion to the block/executor/UI defaults so the default model is consistent everywhere: - agent/router/evaluator/pi block defaultValue + agent runtime fallback - executor AGENT/ROUTER/EVALUATOR DEFAULT_MODEL constants + pi-handler DEFAULT_MODEL - combobox model-field default fallback - update coupled tests (blocks, router/evaluator handlers, copilot model-suggestion validation) to the new default/recommended set Stagehand's anthropic model pin is left on claude-sonnet-4-6 (separate tool-internal choice, not a block default). --- .../components/combobox/combobox.tsx | 6 ++--- apps/sim/blocks/blocks.test.ts | 2 +- apps/sim/blocks/blocks/agent.ts | 4 ++-- apps/sim/blocks/blocks/evaluator.ts | 2 +- apps/sim/blocks/blocks/pi.ts | 2 +- apps/sim/blocks/blocks/router.ts | 4 ++-- apps/sim/executor/constants.ts | 6 ++--- .../evaluator/evaluator-handler.test.ts | 4 ++-- apps/sim/executor/handlers/pi/pi-handler.ts | 2 +- .../handlers/router/router-handler.test.ts | 4 ++-- .../workflow/edit-workflow/validation.test.ts | 2 +- apps/sim/providers/anthropic/core.ts | 4 +++- apps/sim/providers/models.ts | 24 +++++++++++++++++-- 13 files changed, 44 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index ef09dc6ffac..b2856d10013 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -23,7 +23,7 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' /** * Constants for ComboBox component behavior */ -const DEFAULT_MODEL = 'claude-sonnet-4-6' +const DEFAULT_MODEL = 'claude-sonnet-5' const ZOOM_FACTOR_BASE = 0.96 const MIN_ZOOM = 0.1 const MAX_ZOOM = 1 @@ -238,7 +238,7 @@ export const ComboBox = memo(function ComboBox({ /** * Determines the default option value to use. - * Priority: explicit defaultValue > claude-sonnet-4-6 for model field > first option + * Priority: explicit defaultValue > claude-sonnet-5 for model field > first option */ const defaultOptionValue = useMemo(() => { if (defaultValue !== undefined) { @@ -250,7 +250,7 @@ export const ComboBox = memo(function ComboBox({ // Default not available (e.g. provider disabled) — fall through to other fallbacks } - // For model field, default to claude-sonnet-4-6 if available + // For model field, default to claude-sonnet-5 if available if (subBlockId === 'model') { const defaultModelOption = evaluatedOptions.find( (opt) => getOptionValue(opt) === DEFAULT_MODEL diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index b6b1338b9ef..ddf4e78acdb 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -510,7 +510,7 @@ describe.concurrent('Blocks Module', () => { expect(modelSubBlock).toBeDefined() expect(modelSubBlock?.type).toBe('combobox') expect(modelSubBlock?.required).toBe(true) - expect(modelSubBlock?.defaultValue).toBe('claude-sonnet-4-6') + expect(modelSubBlock?.defaultValue).toBe('claude-sonnet-5') }) it('should have LLM tool access', () => { diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index afa55b44db1..89c0d6e9912 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -130,7 +130,7 @@ Return ONLY the JSON array.`, type: 'combobox', placeholder: 'Type or select a model...', required: true, - defaultValue: 'claude-sonnet-4-6', + defaultValue: 'claude-sonnet-5', options: getModelOptions, commandSearchable: true, }, @@ -503,7 +503,7 @@ Return ONLY the JSON array.`, ], config: { tool: (params: Record) => { - const model = params.model || 'claude-sonnet-4-6' + const model = params.model || 'claude-sonnet-5' if (!model) { throw new Error('No model selected') } diff --git a/apps/sim/blocks/blocks/evaluator.ts b/apps/sim/blocks/blocks/evaluator.ts index 01fbfc3a55b..1de2fb19511 100644 --- a/apps/sim/blocks/blocks/evaluator.ts +++ b/apps/sim/blocks/blocks/evaluator.ts @@ -177,7 +177,7 @@ export const EvaluatorBlock: BlockConfig = { type: 'combobox', placeholder: 'Type or select a model...', required: true, - defaultValue: 'claude-sonnet-4-6', + defaultValue: 'claude-sonnet-5', options: getModelOptions, }, ...getProviderCredentialSubBlocks(), diff --git a/apps/sim/blocks/blocks/pi.ts b/apps/sim/blocks/blocks/pi.ts index f040e78ae13..b466ef77587 100644 --- a/apps/sim/blocks/blocks/pi.ts +++ b/apps/sim/blocks/blocks/pi.ts @@ -93,7 +93,7 @@ export const PiBlock: BlockConfig = { type: 'combobox', placeholder: 'Type or select a model...', required: true, - defaultValue: 'claude-sonnet-4-6', + defaultValue: 'claude-sonnet-5', options: getPiModelOptions, commandSearchable: true, }, diff --git a/apps/sim/blocks/blocks/router.ts b/apps/sim/blocks/blocks/router.ts index 16e567029d7..15835f9b8af 100644 --- a/apps/sim/blocks/blocks/router.ts +++ b/apps/sim/blocks/blocks/router.ts @@ -171,7 +171,7 @@ export const RouterBlock: BlockConfig = { type: 'combobox', placeholder: 'Type or select a model...', required: true, - defaultValue: 'claude-sonnet-4-6', + defaultValue: 'claude-sonnet-5', options: getModelOptions, }, ...getProviderCredentialSubBlocks(), @@ -298,7 +298,7 @@ export const RouterV2Block: BlockConfig = { type: 'combobox', placeholder: 'Type or select a model...', required: true, - defaultValue: 'claude-sonnet-4-6', + defaultValue: 'claude-sonnet-5', options: getModelOptions, }, ...getProviderCredentialSubBlocks(), diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index 03d2cebe635..08fdf93a171 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -207,7 +207,7 @@ export const HTTP = { } as const export const AGENT = { - DEFAULT_MODEL: 'claude-sonnet-4-6', + DEFAULT_MODEL: 'claude-sonnet-5', get DEFAULT_FUNCTION_TIMEOUT() { return getMaxExecutionTimeout() }, @@ -242,13 +242,13 @@ export const MEMORY = { } as const export const ROUTER = { - DEFAULT_MODEL: 'claude-sonnet-4-6', + DEFAULT_MODEL: 'claude-sonnet-5', DEFAULT_TEMPERATURE: 0, INFERENCE_TEMPERATURE: 0.1, } as const export const EVALUATOR = { - DEFAULT_MODEL: 'claude-sonnet-4-6', + DEFAULT_MODEL: 'claude-sonnet-5', DEFAULT_TEMPERATURE: 0.1, RESPONSE_SCHEMA_NAME: 'evaluation_response', JSON_INDENT: 2, diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index 664a80a21ae..2f8993e0835 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -481,7 +481,7 @@ describe('EvaluatorBlockHandler', () => { json: () => Promise.resolve({ content: JSON.stringify({ score: 7 }), - model: 'claude-sonnet-4-6', + model: 'claude-sonnet-5', tokens: {}, cost: 0, timing: {}, @@ -494,6 +494,6 @@ describe('EvaluatorBlockHandler', () => { const fetchCallArgs = mockFetch.mock.calls[0] const requestBody = JSON.parse(fetchCallArgs[1].body) - expect(requestBody.model).toBe('claude-sonnet-4-6') + expect(requestBody.model).toBe('claude-sonnet-5') }) }) diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts index 961859c712d..986ab4a211c 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.ts @@ -37,7 +37,7 @@ import type { import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('PiBlockHandler') -const DEFAULT_MODEL = 'claude-sonnet-4-6' +const DEFAULT_MODEL = 'claude-sonnet-5' function asOptString(value: unknown): string | undefined { if (typeof value !== 'string') return undefined diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index d5e54239b48..81c1a62ab55 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -238,12 +238,12 @@ describe('RouterBlockHandler', () => { await handler.execute(mockContext, mockBlock, inputs) - expect(mockGetProviderFromModel).toHaveBeenCalledWith('claude-sonnet-4-6') + expect(mockGetProviderFromModel).toHaveBeenCalledWith('claude-sonnet-5') const fetchCallArgs = mockFetch.mock.calls[0] const requestBody = JSON.parse(fetchCallArgs[1].body) expect(requestBody).toMatchObject({ - model: 'claude-sonnet-4-6', + model: 'claude-sonnet-5', temperature: 0.1, }) }) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index 726c29f70e6..1a83b20593a 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -274,7 +274,7 @@ describe('validateInputsForBlock', () => { expect(result.errors).toHaveLength(1) expect(result.errors[0]?.field).toBe('model') expect(result.errors[0]?.error).toContain('Unknown model id') - expect(result.errors[0]?.error).toContain('claude-sonnet-4-6') + expect(result.errors[0]?.error).toContain('claude-sonnet-5') }) it('rejects legacy claude-4.5-haiku style ids', () => { diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index 57056e6acca..0c27f32f28d 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -84,6 +84,7 @@ const THINKING_BUDGET_TOKENS: Record = { /** * Checks if a model supports adaptive thinking (thinking.type: "adaptive"). + * Sonnet 5 supports ONLY adaptive thinking (manual budget_tokens returns a 400 error). * Opus 4.8 and Opus 4.7 support ONLY adaptive thinking (no extended thinking / budget_tokens). * Opus 4.6 and Sonnet 4.6 support both extended and adaptive thinking — use adaptive. * Opus 4.5 supports effort but NOT adaptive thinking — it uses budget_tokens with type: "enabled". @@ -91,6 +92,7 @@ const THINKING_BUDGET_TOKENS: Record = { function supportsAdaptiveThinking(modelId: string): boolean { const normalizedModel = modelId.toLowerCase() return ( + normalizedModel.includes('sonnet-5') || normalizedModel.includes('opus-4-8') || normalizedModel.includes('opus-4.8') || normalizedModel.includes('opus-4-7') || @@ -105,7 +107,7 @@ function supportsAdaptiveThinking(modelId: string): boolean { /** * Builds the thinking configuration for the Anthropic API based on model capabilities and level. * - * - Opus 4.8, Opus 4.7: Uses adaptive thinking only (no extended thinking support) + * - Sonnet 5, Opus 4.8, Opus 4.7: Uses adaptive thinking only (no extended thinking support) * - Opus 4.6, Sonnet 4.6: Uses adaptive thinking with effort parameter * - Other models: Uses budget_tokens-based extended thinking * diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 3666033af1f..0c3e01ac01e 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -661,7 +661,7 @@ export const PROVIDER_DEFINITIONS: Record = { fileAttachment: { maxBytes: 50 * 1024 * 1024, strategy: 'remote-url' }, name: 'Anthropic', description: "Anthropic's Claude models", - defaultModel: 'claude-sonnet-4-6', + defaultModel: 'claude-sonnet-5', modelPatterns: [/^claude/], icon: AnthropicIcon, color: '#D97757', @@ -669,6 +669,27 @@ export const PROVIDER_DEFINITIONS: Record = { toolUsageControl: true, }, models: [ + { + id: 'claude-sonnet-5', + pricing: { + input: 2.0, + cachedInput: 0.2, + output: 10.0, + updatedAt: '2026-06-30', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + default: 'high', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-06-30', + recommended: true, + }, { id: 'claude-opus-4-8', pricing: { @@ -747,7 +768,6 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1000000, releaseDate: '2026-02-17', - recommended: true, }, { id: 'claude-opus-4-5', From 0613cebfab087f5a77674de8eef35515f4e3c528 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 30 Jun 2026 12:17:55 -0700 Subject: [PATCH 06/27] feat(db): resolve DATABASE_URL per role (DATABASE_URL_ with fallback) (#5276) * feat(db): resolve DATABASE_URL per role (DATABASE_URL_ with fallback) * fix(db): pin realtime process to SIM_DB_ROLE=realtime so both pools share the role Without it, the realtime process left SIM_DB_ROLE unset: the shared @sim/db client defaulted role to 'web' (web pool profile + DATABASE_URL_WEB) while socketDb used 'realtime', so the two pools diverged after cutover. Set it at the process level (bootstrap + dev/start scripts), mirroring DB_APP_NAME, so the shared client and socketDb both resolve the realtime profile and URL. --- apps/realtime/package.json | 4 +- apps/realtime/src/bootstrap.ts | 12 +++-- apps/realtime/src/database/operations.ts | 6 ++- apps/realtime/src/env.ts | 2 + apps/sim/lib/core/config/env.ts | 6 +++ packages/db/connection-url.test.ts | 57 ++++++++++++++++++++++++ packages/db/connection-url.ts | 12 +++++ packages/db/db.ts | 16 ++++--- packages/db/index.ts | 1 + 9 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 packages/db/connection-url.test.ts create mode 100644 packages/db/connection-url.ts diff --git a/apps/realtime/package.json b/apps/realtime/package.json index ce9bbdaec52..83ca341b44d 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -9,8 +9,8 @@ "node": ">=20.0.0" }, "scripts": { - "dev": "DB_APP_NAME=sim-realtime bun --watch src/index.ts", - "start": "DB_APP_NAME=sim-realtime bun src/index.ts", + "dev": "SIM_DB_ROLE=realtime DB_APP_NAME=sim-realtime bun --watch src/index.ts", + "start": "SIM_DB_ROLE=realtime DB_APP_NAME=sim-realtime bun src/index.ts", "type-check": "tsc --noEmit", "lint": "biome check --write --unsafe .", "lint:check": "biome check .", diff --git a/apps/realtime/src/bootstrap.ts b/apps/realtime/src/bootstrap.ts index 1eaea2b80c2..bf0840fb05f 100644 --- a/apps/realtime/src/bootstrap.ts +++ b/apps/realtime/src/bootstrap.ts @@ -7,10 +7,14 @@ import { loadRuntimeSecrets } from '@sim/runtime-secrets' await loadRuntimeSecrets() /** - * Label every Postgres connection this process opens as `sim-realtime` — both - * the realtime `socketDb` pool and the shared `@sim/db` client used by handlers, - * preflight, and permissions. Set before importing `@/index` so it lands before - * `@sim/db` reads it at module-eval time. `??=` respects an explicit override. + * Pin this process to the `realtime` DB role — covering both the realtime + * `socketDb` pool and the shared `@sim/db` client used by handlers, preflight, + * and permissions. The role drives the pool-size profile, `application_name`, + * and the role-keyed connection URL, so every realtime connection resolves + * consistently (without it the shared client would default to `web`). Set + * before importing `@/index` so it lands before `@sim/db` reads it at + * module-eval time; `??=` respects an explicit override. */ +process.env.SIM_DB_ROLE ??= 'realtime' process.env.DB_APP_NAME ??= 'sim-realtime' await import('@/index') diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index 9fbda3fe27d..4c2735a2087 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import * as schema from '@sim/db' import { instrumentPoolClient, + resolveDbUrl, workflow, workflowBlocks, workflowEdges, @@ -31,7 +32,10 @@ import { env } from '@/env' const logger = createLogger('SocketDatabase') -const connectionString = env.DATABASE_URL +// Both realtime pools (this socketDb + the shared @sim/db pool) resolve the +// realtime-keyed URL when set, falling back to the shared DATABASE_URL. +const connectionString = + resolveDbUrl('DATABASE_URL', process.env.SIM_DB_ROLE ?? 'realtime') ?? env.DATABASE_URL // Realtime process footprint = this socketDb pool + the shared @sim/db pool. const socketDb = drizzle( instrumentPoolClient( diff --git a/apps/realtime/src/env.ts b/apps/realtime/src/env.ts index fb1f37a391a..5afdf3a20f3 100644 --- a/apps/realtime/src/env.ts +++ b/apps/realtime/src/env.ts @@ -3,6 +3,8 @@ import { z } from 'zod' const EnvSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), DATABASE_URL: z.string().url(), + DATABASE_URL_REALTIME: z.string().url().optional(), + DATABASE_REPLICA_URL_REALTIME: z.string().url().optional(), REDIS_URL: z.preprocess( (value) => (typeof value === 'string' && value.trim() === '' ? undefined : value), z.string().url().optional() diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index dd88a3ec5bb..7bc8eb44d9a 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -22,6 +22,12 @@ export const env = createEnv({ DATABASE_REPLICA_URL: z.string().url().optional(), // Read-replica connection string; opt-in reads fall back to the primary when unset DB_APP_NAME: z.string().optional(), // Postgres application_name for query attribution (sim-app/sim-trigger/sim-realtime) SIM_DB_ROLE: z.enum(['web', 'trigger', 'realtime']).optional(), // Per-process pool profile selector (read directly by @sim/db) + DATABASE_URL_WEB: z.string().url().optional(), // Per-role primary URL override; @sim/db falls back to DATABASE_URL + DATABASE_URL_TRIGGER: z.string().url().optional(), // Per-role primary URL override (trigger) + DATABASE_URL_REALTIME: z.string().url().optional(), // Per-role primary URL override (realtime) + DATABASE_REPLICA_URL_WEB: z.string().url().optional(), // Per-role replica URL override; falls back to DATABASE_REPLICA_URL + DATABASE_REPLICA_URL_TRIGGER: z.string().url().optional(), // Per-role replica URL override (trigger) + DATABASE_REPLICA_URL_REALTIME: z.string().url().optional(), // Per-role replica URL override (realtime) BETTER_AUTH_URL: z.string().url(), // Base URL for Better Auth service BETTER_AUTH_SECRET: z.string().min(32), // Secret key for Better Auth JWT signing DISABLE_REGISTRATION: z.boolean().optional(), // Flag to disable new user registration diff --git a/packages/db/connection-url.test.ts b/packages/db/connection-url.test.ts new file mode 100644 index 00000000000..5bc0b3f12db --- /dev/null +++ b/packages/db/connection-url.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { resolveDbUrl } from './connection-url' + +describe('resolveDbUrl', () => { + const KEYS = [ + 'DATABASE_URL', + 'DATABASE_URL_WEB', + 'DATABASE_URL_TRIGGER', + 'DATABASE_REPLICA_URL', + 'DATABASE_REPLICA_URL_TRIGGER', + ] as const + const saved: Record = {} + + beforeEach(() => { + for (const key of KEYS) { + saved[key] = process.env[key] + delete process.env[key] + } + }) + + afterEach(() => { + for (const key of KEYS) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + }) + + it('prefers the role-keyed primary URL over the base', () => { + process.env.DATABASE_URL = 'postgres://base/db' + process.env.DATABASE_URL_TRIGGER = 'postgres://trigger/db' + expect(resolveDbUrl('DATABASE_URL', 'trigger')).toBe('postgres://trigger/db') + }) + + it('falls back to the base URL when the keyed var is unset', () => { + process.env.DATABASE_URL = 'postgres://base/db' + expect(resolveDbUrl('DATABASE_URL', 'web')).toBe('postgres://base/db') + }) + + it('returns undefined when neither keyed nor base is set', () => { + expect(resolveDbUrl('DATABASE_URL', 'realtime')).toBeUndefined() + }) + + it('resolves the replica variant independently of the primary', () => { + process.env.DATABASE_REPLICA_URL = 'postgres://replica/db' + process.env.DATABASE_REPLICA_URL_TRIGGER = 'postgres://trigger-replica/db' + expect(resolveDbUrl('DATABASE_REPLICA_URL', 'trigger')).toBe('postgres://trigger-replica/db') + expect(resolveDbUrl('DATABASE_REPLICA_URL', 'web')).toBe('postgres://replica/db') + }) + + it('uppercases the role to build the keyed var name', () => { + process.env.DATABASE_URL_WEB = 'postgres://web/db' + expect(resolveDbUrl('DATABASE_URL', 'web')).toBe('postgres://web/db') + }) +}) diff --git a/packages/db/connection-url.ts b/packages/db/connection-url.ts new file mode 100644 index 00000000000..14203ee6423 --- /dev/null +++ b/packages/db/connection-url.ts @@ -0,0 +1,12 @@ +/** + * Resolve a connection URL for the active DB role, preferring the role-keyed + * variant (e.g. `DATABASE_URL_TRIGGER`) and falling back to the shared base. + * Lets each deploy point its surface at its own Postgres user + PgBouncer via + * env alone; unset keyed vars preserve the prior single-URL behavior. + */ +export function resolveDbUrl( + base: 'DATABASE_URL' | 'DATABASE_REPLICA_URL', + role: string +): string | undefined { + return process.env[`${base}_${role.toUpperCase()}`] ?? process.env[base] +} diff --git a/packages/db/db.ts b/packages/db/db.ts index af469947f15..a122948995e 100644 --- a/packages/db/db.ts +++ b/packages/db/db.ts @@ -1,13 +1,9 @@ import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' +import { resolveDbUrl } from './connection-url' import * as schema from './schema' import { instrumentPoolClient } from './tx-tripwire' -const connectionString = process.env.DATABASE_URL! -if (!connectionString) { - throw new Error('Missing DATABASE_URL environment variable') -} - /** * Per-role pool profiles. Starting numbers — validate against real per-role * process counts (PgBouncer transaction mode, max_connections=200). @@ -28,7 +24,13 @@ if (roleEnv && !Object.hasOwn(DB_POOL_PROFILES, roleEnv)) { `Invalid SIM_DB_ROLE '${roleEnv}' — expected one of ${Object.keys(DB_POOL_PROFILES).join(', ')} (or unset for web)` ) } -const profile = DB_POOL_PROFILES[(roleEnv as DbRole) || 'web'] +const role = (roleEnv as DbRole) || 'web' +const profile = DB_POOL_PROFILES[role] + +const connectionString = resolveDbUrl('DATABASE_URL', role) +if (!connectionString) { + throw new Error('Missing DATABASE_URL environment variable') +} const poolOptions = { prepare: false, @@ -51,7 +53,7 @@ export const db = drizzle(postgresClient, { schema }) * for auth, workflow state, or billing enforcement. Falls back to the primary * when `DATABASE_REPLICA_URL` is unset, so call sites never branch. */ -const replicaUrl = process.env.DATABASE_REPLICA_URL +const replicaUrl = resolveDbUrl('DATABASE_REPLICA_URL', role) if (replicaUrl && !/^postgres(ql)?:\/\//.test(replicaUrl)) { throw new Error( 'DATABASE_REPLICA_URL is set but is not a postgres:// DSN — fix the URL or unset the variable' diff --git a/packages/db/index.ts b/packages/db/index.ts index a28cf889527..a472c56fa07 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -1,3 +1,4 @@ +export * from './connection-url' export * from './db' export * from './schema' export * from './triggers' From 84c22f194866e24db22b90a82a9fef0dff021125 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 30 Jun 2026 13:17:11 -0700 Subject: [PATCH 07/27] fix(emcn): keep Prism grammar registrations in bundle, never throw on missing grammar (#5293) Consumers import { highlight, languages } from the @sim/emcn barrel, which re-exported Prism's highlight straight from prismjs. Bundlers resolved that passthrough directly from prismjs and skipped prism.ts's module body, dropping the side-effect grammar registrations so languages.json (etc.) were undefined at runtime. Prism then threw 'The language "json" has no grammar.', crashing the start-block file[] input format field and every other workflow-editor code highlighter. Own highlight as a local wrapper so the registrations stay in the dependency graph, and degrade to escaped plaintext when a grammar is missing instead of throwing. --- packages/emcn/src/components/code/prism.ts | 28 +++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/emcn/src/components/code/prism.ts b/packages/emcn/src/components/code/prism.ts index 0aebddbf6ae..9d7b7b3c635 100644 --- a/packages/emcn/src/components/code/prism.ts +++ b/packages/emcn/src/components/code/prism.ts @@ -1,4 +1,4 @@ -import { highlight, languages } from 'prismjs' +import { type Grammar, languages, highlight as prismHighlight } from 'prismjs' import 'prismjs/components/prism-javascript' import 'prismjs/components/prism-python' import 'prismjs/components/prism-json' @@ -10,10 +10,32 @@ import 'prismjs/components/prism-json' * shared `Prism.languages` registry), which marks any module that statically * imports them as having side effects and therefore non-tree-shakeable. Keeping * them here — rather than in `code.tsx` — ensures Prism only enters bundles that - * actually import `highlight`/`languages`, instead of every consumer of the - * shared `@sim/emcn` barrel (which re-exports `Code`). + * actually import these utilities, instead of every consumer of the shared + * `@sim/emcn` barrel (which re-exports `Code`). * * `code.tsx` itself never imports this module statically; it loads it lazily via * dynamic `import()` on first highlight. + * + * `highlight` is a local wrapper rather than a re-export of Prism's `highlight`. + * A bare re-export lets bundlers resolve the binding straight from `prismjs` and + * skip this module's body, dropping the grammar registrations above so + * `languages.json` (etc.) become `undefined` at runtime. Owning the function + * keeps the registrations in the dependency graph and lets us degrade to escaped + * plaintext when a grammar is missing instead of throwing. + */ + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>') +} + +/** + * Highlights `code` with the given Prism `grammar`, returning HTML markup. + * Falls back to escaped plaintext when `grammar` is undefined so a missing or + * unregistered language never throws `The language "" has no grammar.`. */ +function highlight(code: string, grammar: Grammar | undefined, language: string): string { + if (!grammar) return escapeHtml(code) + return prismHighlight(code, grammar, language) +} + export { highlight, languages } From ccddb629d225685fdfc17bd968a2c874113c9548 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 30 Jun 2026 13:48:02 -0700 Subject: [PATCH 08/27] feat(broadcast): add LinkedIn footer social icon (#5295) --- apps/sim/public/static/linkedin-icon.png | Bin 0 -> 652 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 apps/sim/public/static/linkedin-icon.png diff --git a/apps/sim/public/static/linkedin-icon.png b/apps/sim/public/static/linkedin-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fd97e14b0c4ce8cd3f4baedf663580267742c200 GIT binary patch literal 652 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}E~ycoX}-P; zT0k}j17mw80}DtA5K93u0|WB{Mh0de%?J`(zyz07Sip>6gA}f5OZp5{_RZ79F(iWX zZK$pHVFw8_`IaAz9Jwk{1;T|FU1eMr)-Rgv6v#3?!fWO1{Rb}au&S4SmhX5Ju{7es z$v^=?7S`7xEF#r7b)vI=O z-PrN|xBIr-c$cT02P~Lx=7jmHO*ZwMls1!r=SRl1#H9EZg{KoV6C{pBDV$4sVLZ7^ zZE|47x(>s125!H=qzVZ`-{J|6l#b0|Jpb8G!_X)6^wUk=g@VcF8Xj9bSGafN_bC>I zy3VxLuk~LOXK#$qnPsngYHrheu4hF%Ib|MZ9&6rP+_D}TN)KIOdSuFYdJ8c(ebakCjsXX3vQb1n8 zaN2H`!m{!u&3gi@+0Stq%N??cJ?EhQY3n(&Bkj9=E+*9Q7y9)x@0w5})l{KB{ffw{ z4Gx|y5(!Igs9S%nvTZF}aWzYqxoUBeftA1Ex4o`5$8Ri&R$Sz~ri?9>KUv2{>QrWV zYY4;YXLzV&o5Ue-`Mb%0s5ZKZmbYj3N>fm1eKeV>XY&L$=+vMuVUKAOZ_$og*& z(;eRzyWV*#O)NOHTjKZ|D+zO{-fG{=c1H@I Date: Tue, 30 Jun 2026 15:50:04 -0500 Subject: [PATCH 09/27] improvement(landing): refine hero and mothership visuals (#5181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * stash * feat(landing): mothership feature stages + pre-footer CTA Tell-then-show landing: the Mothership section defines the five capabilities (Mothership · Pod · Formation · Dispatch · Return); the Features section now shows each as a real Sim UI callout floating over a static, edge-faded platform backdrop (Linear's "callout over a faded platform" pattern). - FeatureStage template: copy + masked static LandingPreview + elevated callout - LandingPreview: static autoplay=false snapshots with per-stage view/workflowId - Callouts: Mothership chat, model picker, parallel-agents Formation graph, deploy targets, logs table - Pre-footer CTA set over the Mothership render; removed the old capabilities grid Co-Authored-By: Claude Opus 4.8 * feat(landing): reusable platform-page + solutions-page layouts and routes Add config-driven, padding-safe layouts consumed by route pages: - platform-page: hero (shared CTA) + centered logos + N card rows (3|4), JSON-LD, single

, server-only; Workflows route as first consumer. - solutions-page: structural mirror (kept separate to diverge later); IT, Engineering, Finance, Compliance, HR routes under /solutions. - Hoist shared LandingShell/HeroCta/Logos to components/ (top-level = shared); refactor hero to consume them. - Restructure all of (landing) to the workspace folder-per-component convention (each component in its own folder + index.ts barrel). * refactor(landing): convert hero-visual CSS-module keyframes to Tailwind Move the hero-visual + stage-home keyframe animations out of CSS modules into tailwind.config (matching the existing dash-animation pattern) and delete both module.css files. Components now use animate-hero-* utilities + arbitrary properties for the per-element delays, SVG stroke draw, and gradient shimmer; reduced-motion preserved via motion-reduce: variants. Upgrade the shimmer's hardcoded #b4b4b4 to the --text-subtle token. brand-tokens.module.css is intentionally kept: it reassigns --surface-*/ --text-* token VALUES via a doubled-class selector for specificity over .light, which Tailwind utilities cannot express. * refactor(landing): move brand palette from CSS module into LandingShell Replace brand-tokens.module.css with a BRAND_TOKENS constant of Tailwind arbitrary-property utilities applied on the LandingShell wrapper, so the brand hex lives in the component, not a stylesheet. They emit in the utilities layer and override .light (@layer base) by cascade order — verified the brand --text-primary (#121212) wins over .light (#1a1a1a). No more .module.css files remain in the landing. * chore(landing): remove Testimonials from the home page for now Drop from the landing composition (component kept for re-adding later). * feat(landing): hero send→loader→workflow animation + landing WIP Hero visual: clicking send zooms into the button, morphs the disc into the gooey thinking loader (held, then cycling), slides it straight across to a phrase indicator with the camera following (no zoom-out), then zooms back out as the reply types and the chat morphs into the GitHub→Agent→Jira workflow. The chat card holds a fixed size through the zoomed scene and the greeting reserves its space, so nothing drifts; the user bubble reveals only on zoom-out. Loader ink tweens dark→gradient via the thinking-loader stop-color/flood-color transition. Also folds in in-progress landing work: knowledge + integrations feature callouts, CTA chat, mothership + line-glyph, wordmark tweak; removes the ethos and testimonials sections. Co-Authored-By: Claude Opus 4.8 * feat(landing): responsive pass for iPad + mobile Make the landing page fully responsive while keeping the desktop layout byte-identical (desktop classes stay the unprefixed baseline; smaller screens layer max-* overrides on top). - Navbar: hide desktop clusters below lg, add MobileNav hamburger sheet (scroll-lock, Escape/tap close, reduced-motion aware) - Hero: collapse the absolute split (visual + logos) to a stacked column below xl so iPad-landscape avoids the headline/visual collision - Mothership: 4-col grid steps to 2 (tablet) then 1 (phone) - Features: drop the floating callout below md, show the un-masked backdrop preview full-width - CTA + Footer: scale type/padding; footer 7-col steps to 3 then 2 - Document the breakpoint strategy in the landing CLAUDE.md Also includes the in-progress mothership goo/iso brand marks and the marks-lab preview route the section depends on. Co-Authored-By: Claude Opus 4.8 * feat(landing): align hero visual panel to text + logos extent Co-Authored-By: Claude Opus 4.8 * fix(landing): delay hero user bubble until card finishes expanding The grey user bubble's fade-in raced the card's upward grow on send. Hold the bubble's reveal until after the parent-driven grow settles so the card expands fully before the bubble appears. Co-Authored-By: Claude Opus 4.8 * refactor(landing): isolate new landing — remove dead old-folder code + --landing-* coupling - Delete dead (landing) auth-modal (a duplicate of (home)'s, still on the old --landing-* / dark tokens) — its removal drops the new landing's last styling tie to the old landing. - Delete 5 merge-orphaned, zero-consumer callouts (deploy-callout, mothership-chat-callout, mothership-chat-preview, workflow-graph-preview, model-picker-preview). - Relocate the one live preview (logs-table-preview) into its consumer features/components/ + add a barrel; dissolve the owner-less feature-callouts/ shell. - Fix stale --landing-bg-surface reference in landing-preview-mount. (landing) now has zero (home) imports and zero --landing-* token usage. * refactor(landing): token-map hex, fix a11y/SEO, align structure Styling (within (landing)): - Replace ~90 hardcoded hex colors with the in-scope brand tokens they already equal (--surface-*/--text-*/--border*); divider edges -> --border, field/card edges -> --border-1. Delete the redundant C color-palette mirrors in the landing-preview home/sidebar and route them through tokens. - Convert static inline SVG styles (display:block/outline:none) to Tailwind. - 6 un-tokenizable hexes remain (dark send-button fills, status-green dot) — no brand token exists; left as-is. a11y / SEO: - Decorative mothership goo/iso marks: role='img'+aria-label -> aria-hidden. - Preview chrome titles

-> (kills duplicate client-only H1s). - sitemap.ts: add /workflows and the five /solutions/* routes. Structure: - Folder the bare logo-mark/mobile-nav leaves + barrels; complete the navbar components barrel and consolidate navbar.tsx to a single barrel import. * style(landing): restore taller hero panel with border-shadow chip chrome - Revert the right visual panel to the previous full-height framing (top-8 bottom-8) — hero text (pt-[112px]) and the logos panel are untouched, so their positions and spacing are unchanged. - Apply the canonical border-shadow chip surface: --surface-2 fill + the shared chipBorderShadowRing (1px hairline ring + soft drop shadow) from emcn, the documented chrome for a landing media panel. * feat(landing): swap Volvo for thinkproject and reposition hero logos - Replace Volvo with the thinkproject wordmark (official SVG, tagline/descriptor cropped out, all paths unified to --text-primary #1a1a1a; aspect 6.01). - Reorder the shared 6-logo set so the 3x2 hero grid reads: Rivian|VW (top-left), eXp Realty (top-center), Russell (top-right); Artie (bottom-left), thinkproject (bottom-center), Mobile Health (bottom-right). - Enlarge Rivian|VW a touch (height 15 -> 17, same aspect). - eXp Realty, Artie, Russell, Mobile Health, Rivian|VW all retained. * style(landing): size hero description with the type scale (text-lg) Replace the arbitrary text-[20px]/text-[16px] on the hero description with named scale tokens — text-lg (18px) desktop, text-md (16px) on phones — a touch smaller and the canonical lead size (1.2x the platform's 15px base). * style(landing): hero headline "for AI automations" with break after "agent" Replace "solving automations" with the higher-intent "AI automations" and move the line break after "agent" so "for AI automations." sits on the second line. * style(landing): unify CTA radius and box hero logos in cards - HeroCta email bar: rounded-[13px] -> rounded-lg, so the bar, the inset Book-a-demo chip, the Sign-up chip, and the navbar chips all share one radius. - Hero logos: box each wordmark in a bordered --surface-1 card (platform card chrome: rounded-lg + --border-1, 100px tall) on a responsive 3-up grid (2-up on phones) at a consistent gap-5 rhythm. Wide marks scale to fit (max-w-full h-auto). The platform/solutions 'row' layout stays bare wordmarks. * style(landing): concentric CTA bar radius + tighter logo cards - HeroCta email bar back to rounded-[13px] (= inner chip 8px + ~5px inset) so the Book-a-demo chip's right corners nest concentrically inside the bar. - Logo cards: smaller and tighter — h-20 (80px), px-4, gap-3 (12px, the product UI card-grid rhythm). * style(landing): restore 100px logo cards, scale icons down 15% The 80px cards read too wide-for-their-height. Restore h-[100px] (keeping the tighter gap-3/px-4) and instead shrink the wordmarks to 0.85x their optical size in the grid via GRID_ICON_SCALE — row layout unchanged. * style(landing): match sign-up radius to email bar + shrink logo icons - Sign-up chip overridden to the email bar's rounded-[13px], so the two hero CTAs share one corner radius. - Logo icons: GRID_ICON_SCALE 0.85 -> 0.65 and card padding px-4 -> px-2; card dimensions (h-[100px], gap-3) unchanged. * style(landing): shrink hero logo cards Cards read massive — too tall (100px) and stretched to fill the panel. Drop to h-16 (64px), cap width at w-[150px], and make the grid w-fit so it hugs the cards instead of stretching. gap-3 and the 0.65 icon scale unchanged. * style(landing): upscale hero logo cards ~25% Cards read too small. Bump all dimensions: h-16->h-20 (80px), w-[150px]->w-[180px], px-2->px-3, and icon scale 0.65->0.8. Grid stays content-hugging at gap-3. * style(landing): taller logo cards, larger icons, reorder top row - Card height h-20 -> h-[88px] (width w-[180px] unchanged), icon scale 0.8 -> 0.85. - Top row reordered: eXp (left), Russell (center), Rivian|VW (right). * style(landing): more card height, swap top-row Rivian/eXp back - Card height h-[88px] -> h-24 (96px); width unchanged. - Top row: Rivian|VW (left), Russell (center), eXp (right). * feat(landing): add "Trusted by technical teams at" label above hero logos Top-left, gap-3 above the logo grid (matching the grid rhythm); text-sm (navbar text size) in --text-muted (the label token). * style(landing): recolor logos to --text-body, match label gap to hero rhythm - Recolor all six customer logo SVGs to #3b3b3b (--text-body light value), so they match the Sim navbar wordmark's color. Landing is light-only, so the hardcoded value always equals var(--text-body). - Trusted-by label gap gap-3 -> gap-[22px] (the hero's description->CTA spacing). * style(landing): scale hero CTA down a hair, drop radius to the nav chip's Sign-up read too round. Take the bar + Sign-up to h-[40px] / rounded-lg (8px, the navbar chip radius), and keep the inset Book-a-demo concentric: h-[2em] + rounded (4px) with a 4px inset (8 = 4 + 4). * style(landing): round Book-a-demo to rounded-md to match the bar curve rounded (4px) read too square next to the bar's rounded-lg (8px). Bump to rounded-md (6px) — echoes the bar's curvature, still inside the 4px inset. * style(landing): match Book-a-demo proportions to the navbar chip Restore h-[2.143em] (the chip's 30/14 height ratio); with px-[0.571em] (its 8/14 padding ratio) and the 16px label, Book-a-demo now shares the navbar chip's exact height/padding/text proportions. * style(landing): equal inset around Book-a-demo (h-[30px]) Button was h-[2.143em] (34.3px) -> only ~1.9px top/bottom vs 4px right inside the bar's 38px inner box (40px minus the 1px border). Drop to h-[30px] (the nav chip height) so it centers to an equal 4px inset on top, bottom, and right. * style(landing): enlarge Book-a-demo to h-[32px], tighten inset to 3px h-[30px] read too small/airy in the bar. Bump to h-[32px] and pr-[4px] -> pr-[3px] so the inset is an equal, snugger 3px on top, bottom, and right. * style(landing): lift hero logos off the bottom again (pb-20) Restore the 80px bottom padding so the logos rest 112px above the section bottom (mirroring the hero text's 112px top) instead of sitting flush with the visual panel's bottom. max-xl:pb-0 keeps the stacked layout tight. * improvement(landing): refine hero and mothership visuals * fix(landing): cap hero fold height so it doesn't stretch on huge monitors The section was min-h-[calc(100vh-62px)], so on very tall displays both absolute panels (top-8 bottom-8) stretched — the visual panel grew gigantic and the bottom-anchored logos sank to the very bottom. Cap the fold at 960px via h-[min(calc(100vh-62px),960px)] (min-height can't be capped by max-height): the whole hero stops growing, panels/logos stay proportioned like a large laptop, and the next section just starts below. Laptops (<=16in) are unaffected; max-xl:h-auto keeps the stacked layout below xl. * refactor(landing): session cleanup — DRY CTA label, drop dead grayscale Final tidy after this session's hero/CTA/logo iteration: - hero-cta: extract the duplicated 16px label knob (px-[0.571em] + text-[16px] + font-size:inherit) into a single CTA_LABEL constant, matching the 'single knob' the TSDoc already describes — used by both Book-a-demo and Sign-up. - logos: remove the grayscale filter (now a no-op — all wordmarks were recolored to a single #3b3b3b), inline the single-use LOGO_GAP_X, and flatten the nested cn() into plain layout ternaries (dropping the now-unused cn import). * improvement(landing): animate mothership illustrations * style(landing): solid-ink branding + hero cursor/loader polish Branding: drop the bespoke BRAND_TOKENS palette and bottom-reveal from LandingShell (use the platform's own light tokens); re-ink the wordmark, logo-mark, and hero loader from the gradient+glow to a solid --text-body so the marks read as one ink with the nav text. Add a `shimmer` prop to ThinkingLoader for a static --text-body label, and stroke the squeeze arcs with the shared gradient. Hero visual: the cursor now enters from below the field and chases the send button live through the zoom (retimed beats, no arrive-then-wait); the greeting fades in gently instead of shimmer-revealing; the click ring becomes a press-dip (hero-cursor-press replaces hero-click-ring and hero-greeting-reveal). Extract BlockHandles so the morphed GitHub card carries a real edge handle in scene space; seed the compose card at its true height; pop the sent bubble in immediately. * improvement(landing): update feature iso-marks to perfected geometry Re-author the four Mothership iso-mark illustrations (Integrate, Ingest, Build, Monitor) on the refined isometric geometry, keeping the existing animation vocabulary intact: hover line-draw plus per-mark auto-motion (integrate float, ingest pulse, monitor panel-separate, build grid-flow). Map the raw exports onto the shared token palette/line weight for consistency and tune per-mark sizes for one optical weight. Build is now pure CSS (grid-flow replaces the RAF wave), so it drops 'use client' and renders as a server component. Co-Authored-By: Claude Opus 4.8 * feat(landing): add pricing, privacy, terms, and changelog pages - New public /pricing page: Free/Pro/Max/Enterprise cards with the full comparison breakdown transposed from shared upgrade data + JSON-LD; prices, CTAs, and features derive from shared billing constants so they can't drift from the in-app upgrade page. - Migrate /privacy, /terms, and /changelog into the (landing) route group via a shared prose-page system (single source of truth for legal/prose chrome). - Landing polish: solid-ink iso-mark illustrations + footer/cta/features/ mothership spacing and token cleanups; sitemap adds /pricing. - Audit pass: crawlable ChipLink CTAs, correct heading hierarchy, structured-data featureList derived from the visible comparison data, legal plan name Team->Max. * large edits across landing finalization * feat(auth): port OAuth-only signup + Microsoft provider from staging Align auth-page logic with origin/staging (PR #5073) while keeping the new chip-styled UI: - Add Microsoft as a better-auth social sign-in provider (auth.ts) and surface it through the OAuth provider checker, providers API + contract, login/signup forms, SocialLoginButtons, and the landing auth modal. - Gate email/password signup behind the emailSignupEnabled server flag (DISABLE_EMAIL_SIGNUP) so signup becomes OAuth-only when configured. - Add DISABLE_MICROSOFT_AUTH / DISABLE_EMAIL_SIGNUP env + feature flags. * fix(icons): render brand icons legibly when bare and on light tiles (#5292) Monochrome brand icons hardcoded a single white or black fill matched to their colored tile, so they vanished when rendered bare on the home Suggested actions list (white-on-white in light mode, black-on-black in dark mode). Convert those marks to currentColor so they adapt to context, and make tile foregrounds contrast-aware via getTileIconColorClass instead of a hardcoded text-white. Also centralize all color math in apps/sim/lib/colors (perceived brightness, hex/rgb/hsl conversion, contrast-text) and route every consumer through it: the bare-icon audit, block tiles, logs trace view, whitelabeling theming, workspace presence, and the PPTX renderer no longer carry duplicate copies. Adds a bare-icon CI audit (scripts/check-bare-icons.ts) and authoring guidance. --------- Co-authored-by: Emir Karabeg Co-authored-by: andresdjasso Co-authored-by: Claude Opus 4.8 Co-authored-by: Waleed --- .agents/skills/design-taste-frontend/SKILL.md | 1206 +++++++++++++++++ .agents/skills/emil-design-eng/SKILL.md | 679 ++++++++++ .claude/commands/add-block.md | 7 + .claude/commands/add-integration.md | 26 + .claude/skills/design-taste-frontend | 1 + .claude/skills/emil-design-eng | 1 + .cursor/rules/constitution.mdc | 2 +- .cursor/rules/landing-seo-geo.mdc | 2 +- .github/workflows/test-build.yml | 3 + apps/sim/app/(auth)/auth-layout-client.tsx | 26 +- .../app/(auth)/components/auth-divider.tsx | 21 + apps/sim/app/(auth)/components/auth-field.tsx | 41 + .../(auth)/components/auth-form-message.tsx | 28 + .../sim/app/(auth)/components/auth-header.tsx | 21 + apps/sim/app/(auth)/components/auth-input.tsx | 20 + .../(auth)/components/auth-legal-footer.tsx | 26 + .../app/(auth)/components/auth-nav-prompt.tsx | 27 + apps/sim/app/(auth)/components/auth-shell.tsx | 40 + .../(auth)/components/auth-submit-button.tsx | 49 + .../app/(auth)/components/auth-text-link.tsx | 58 + apps/sim/app/(auth)/components/constants.ts | 17 + apps/sim/app/(auth)/components/index.ts | 14 + .../app/(auth)/components/password-input.tsx | 37 + .../components/social-login-buttons.tsx | 90 +- .../(auth)/components/sso-login-button.tsx | 23 +- .../(auth)/components/status-page-layout.tsx | 44 - .../app/(auth)/components/support-footer.tsx | 8 +- apps/sim/app/(auth)/login/login-form.tsx | 251 ++-- .../reset-password/reset-password-content.tsx | 40 +- .../reset-password/reset-password-form.tsx | 214 +-- apps/sim/app/(auth)/signup/signup-form.tsx | 328 ++--- apps/sim/app/(auth)/verify/verify-content.tsx | 145 +- .../chat/[identifier]/chat.tsx | 6 +- .../chat/[identifier]/loading.tsx | 0 .../chat/[identifier]/office-embed-init.tsx | 0 .../chat/[identifier]/page.tsx | 4 +- .../chat/components/auth/email/email-auth.tsx | 260 ++++ .../auth/password/password-auth.tsx | 134 ++ .../components/error-state/error-state.tsx | 28 + .../chat/components/header/header.tsx | 0 .../chat/components/index.ts | 0 .../chat/components/input/input.tsx | 2 +- .../chat/components/input/voice-input.tsx | 0 .../loading-state/loading-state.tsx | 0 .../message-container/message-container.tsx | 5 +- .../message/components/file-download.test.tsx | 2 +- .../message/components/file-download.tsx | 2 +- .../message/components/markdown-renderer.tsx | 0 .../chat/components/message/message.test.tsx | 6 +- .../chat/components/message/message.tsx | 4 +- .../voice-interface/components/particles.tsx | 0 .../voice-interface/voice-interface.tsx | 2 +- .../app/{ => (interfaces)}/chat/constants.ts | 0 .../{ => (interfaces)}/chat/hooks/index.ts | 0 .../chat/hooks/use-audio-streaming.ts | 0 .../chat/hooks/use-chat-streaming.ts | 4 +- apps/sim/app/(interfaces)/components/index.ts | 1 + .../components/interfaces-shell/index.ts | 1 + .../interfaces-shell/interfaces-shell.tsx | 22 + apps/sim/app/(interfaces)/layout.tsx | 13 + .../[executionId]/[contextId]/page.tsx | 0 .../[workflowId]/[executionId]/loading.tsx | 0 .../[workflowId]/[executionId]/page.tsx | 2 +- .../[executionId]/resume-page-client.tsx | 2 +- apps/sim/app/(landing)/CLAUDE.md | 101 ++ .../sim/app/(landing)/blog/[slug]/loading.tsx | 50 +- apps/sim/app/(landing)/blog/[slug]/page.tsx | 118 +- .../(landing)/blog/[slug]/share-button.tsx | 2 +- .../(landing)/blog/authors/[id]/loading.tsx | 12 +- .../app/(landing)/blog/authors/[id]/page.tsx | 20 +- apps/sim/app/(landing)/blog/layout.tsx | 55 +- apps/sim/app/(landing)/blog/loading.tsx | 80 +- apps/sim/app/(landing)/blog/page.tsx | 259 ++-- apps/sim/app/(landing)/blog/tags/loading.tsx | 4 +- apps/sim/app/(landing)/blog/tags/page.tsx | 29 +- .../sim/app/(landing)/changelog/changelog.tsx | 50 + .../changelog-actions/changelog-actions.tsx | 38 + .../components/changelog-actions/index.ts | 1 + .../changelog-timeline/changelog-timeline.tsx | 225 +++ .../components/changelog-timeline/index.ts | 1 + .../(landing)/changelog/components/index.ts | 2 + apps/sim/app/(landing)/changelog/page.tsx | 61 + apps/sim/app/(landing)/changelog/types.ts | 26 + apps/sim/app/(landing)/changelog/utils.ts | 43 + .../components/auth-modal/auth-modal.tsx | 30 +- .../back-link}/back-link.tsx | 23 +- .../(landing)/components/back-link/index.ts | 1 + .../chevron-arrow/chevron-arrow.tsx | 36 + .../components/chevron-arrow/index.ts | 1 + .../collaboration/collaboration.tsx | 355 ----- .../components/contact/contact-form.tsx | 324 ----- .../components/cta/components/cta-chat.tsx | 32 + apps/sim/app/(landing)/components/cta/cta.tsx | 40 + .../demo-request/demo-request-modal.tsx | 285 ---- .../components/external-redirect.tsx | 18 - .../feature-stage/feature-stage.tsx | 111 ++ .../features/components/features-preview.tsx | 1142 ---------------- .../formation-graph/formation-graph.tsx | 77 ++ .../integrations-callout.tsx | 62 + .../knowledge-callout/knowledge-callout.tsx | 45 + .../components/logs-table-preview/index.ts | 1 + .../logs-table-preview/logs-table-preview.tsx | 168 +++ .../components/features/features.tsx | 398 ++---- .../(landing)/components/features/index.ts | 1 + .../components/footer/footer-cta.tsx | 145 -- .../(landing)/components/footer/footer.tsx | 187 +-- .../app/(landing)/components/footer/index.ts | 1 + .../components/forms/landing-field.tsx | 49 - .../components/hero-cta/hero-cta.tsx | 76 ++ .../(landing)/components/hero-cta/index.ts | 1 + .../components/hero-visual/hero-visual.tsx | 1191 ++++++++++++++++ .../components/hero-visual/stage-home.tsx | 413 ++++++ .../hero/components/hero-visual/stage-kb.tsx | 187 +++ .../components/hero-visual/stage-workflow.tsx | 132 ++ .../components/hero-visual/workflow-block.tsx | 121 ++ .../components/hero-visual/workflow-data.ts | 361 +++++ .../components/hero/components/index.ts | 1 + .../app/(landing)/components/hero/hero.tsx | 195 ++- .../app/(landing)/components/hero/index.ts | 1 + .../home-structured-data.tsx | 149 ++ .../components/home-structured-data/index.ts | 1 + apps/sim/app/(landing)/components/index.ts | 54 +- .../(landing)/components/landing-faq/index.ts | 1 + .../{ => landing-faq}/landing-faq.tsx | 14 +- .../landing-preview-chat/chat-input.tsx | 98 ++ .../landing-preview-chat/chat-title-bar.tsx | 44 + .../landing-preview-chat.tsx | 116 ++ .../landing-preview-home.tsx | 376 +++-- .../landing-preview-logs.tsx | 60 +- .../landing-preview-panel.tsx | 498 ------- .../landing-preview-resource.tsx | 10 +- .../landing-preview-sidebar.tsx | 69 +- .../landing-preview-stage-header.tsx | 38 + .../landing-preview-tables.tsx | 23 +- .../landing-preview-workflow.tsx | 57 +- .../preview-block-node.tsx | 62 +- .../landing-preview-workflow/workflow-data.ts | 113 +- .../hooks/use-landing-submit.ts | 29 + .../landing-preview/landing-preview-mount.tsx | 39 + .../landing-preview/landing-preview.tsx | 253 ++-- .../components/landing-shell/index.ts | 1 + .../landing-shell/landing-shell.tsx | 52 + .../app/(landing)/components/legal-layout.tsx | 32 - .../components/lifecycle/components/index.ts | 1 + .../components/lifecycle-icons/index.ts | 1 + .../lifecycle-icons/lifecycle-icons.tsx | 128 ++ .../(landing)/components/lifecycle/index.ts | 1 + .../components/lifecycle/lifecycle.tsx | 83 ++ .../(landing)/components/logo-shell/index.ts | 1 + .../components/logo-shell/logo-shell.tsx | 47 + .../app/(landing)/components/logos/index.ts | 1 + .../app/(landing)/components/logos/logos.tsx | 126 ++ .../components/goo-marks/fan-rings.tsx | 55 + .../components/goo-marks/flower-of-life.tsx | 63 + .../components/goo-marks/goo-defs.tsx | 25 + .../components/goo-marks/goo-mark-svg.tsx | 64 + .../components/goo-marks/grid-9.tsx | 76 ++ .../mothership/components/goo-marks/index.ts | 24 + .../components/goo-marks/lissajous-32.tsx | 49 + .../components/goo-marks/lissajous-morph.tsx | 87 ++ .../components/goo-marks/use-goo-hover.ts | 119 ++ .../components/goo-marks/woven-torus.tsx | 62 + .../components/iso-marks/goo-defs.tsx | 41 + .../mothership/components/iso-marks/index.ts | 40 + .../iso-marks/iso-build-illustration.tsx | 221 +++ .../components/iso-marks/iso-cube-grid.tsx | 149 ++ .../components/iso-marks/iso-cube-row.tsx | 142 ++ .../components/iso-marks/iso-four-box.tsx | 162 +++ .../components/iso-marks/iso-grid-plane.tsx | 82 ++ .../iso-marks/iso-illustration-style.ts | 31 + .../iso-marks/iso-ingest-illustration.tsx | 323 +++++ .../iso-marks/iso-integrate-illustration.tsx | 188 +++ .../iso-marks/iso-monitor-illustration.tsx | 229 ++++ .../iso-marks/iso-stacked-planes.tsx | 127 ++ .../components/iso-marks/iso-star.tsx | 122 ++ .../components/iso-marks/use-goo-mark.ts | 188 +++ .../components/mothership/mothership.tsx | 102 ++ .../navbar/components/blog-dropdown.tsx | 94 -- .../navbar/components/docs-dropdown.tsx | 95 -- .../components/github-chip/github-chip.tsx | 30 + .../navbar/components/github-chip/index.ts | 1 + .../navbar/components/github-stars.tsx | 27 - .../components/navbar/components/index.ts | 13 + .../navbar/components/logo-mark/index.ts | 1 + .../navbar/components/logo-mark/logo-mark.tsx | 67 + .../navbar/components/mobile-nav/index.ts | 1 + .../components/mobile-nav/mobile-nav.tsx | 177 +++ .../components/nav-menu-item/index.ts | 1 + .../nav-menu-item/nav-menu-item.tsx | 62 + .../components/nav-menu-chip/constants.ts | 128 ++ .../navbar/components/nav-menu-chip/index.ts | 3 + .../nav-menu-chip/nav-menu-chip.tsx | 87 ++ .../navbar/components/nav-menu-chip/types.ts | 27 + .../navbar/components/navbar-shell/index.ts | 1 + .../components/navbar-shell/navbar-shell.tsx | 103 ++ .../navbar/components/sim-wordmark/index.ts | 1 + .../components/sim-wordmark/sim-wordmark.tsx | 32 + .../app/(landing)/components/navbar/index.ts | 1 + .../(landing)/components/navbar/navbar.tsx | 576 ++------ .../(landing)/components/not-found-view.tsx | 40 - .../platform-page/components/index.ts | 4 + .../platform-card-row/components/index.ts | 2 + .../components/platform-card/index.ts | 1 + .../platform-card/platform-card.tsx | 42 + .../components/platform-pill-cta/index.ts | 1 + .../platform-pill-cta/platform-pill-cta.tsx | 47 + .../components/platform-card-row/index.ts | 1 + .../platform-card-row/platform-card-row.tsx | 72 + .../components/platform-hero/index.ts | 1 + .../platform-hero/platform-hero.tsx | 58 + .../components/platform-logos-row/index.ts | 1 + .../platform-logos-row/platform-logos-row.tsx | 25 + .../platform-structured-data/index.ts | 1 + .../platform-structured-data.tsx | 72 + .../components/platform-visual-frame/index.ts | 1 + .../platform-visual-frame.tsx | 41 + .../components/platform-page/constants.ts | 60 + .../components/platform-page/index.ts | 8 + .../platform-page/platform-page.tsx | 59 + .../components/platform-page/types.ts | 96 ++ .../(landing)/components/pricing/pricing.tsx | 288 ---- .../components/prose-page/components/index.ts | 5 + .../legal-block-group/components/index.ts | 1 + .../components/legal-block/index.ts | 1 + .../components/legal-block/legal-block.tsx | 38 + .../components/legal-block-group/index.ts | 1 + .../legal-block-group/legal-block-group.tsx | 26 + .../components/legal-section/index.ts | 1 + .../legal-section/legal-section.tsx | 32 + .../prose-page/components/prose-hero/index.ts | 1 + .../components/prose-hero/prose-hero.tsx | 36 + .../prose-page/components/prose-link/index.ts | 1 + .../components/prose-link/prose-link.tsx | 49 + .../components/prose-shell/index.ts | 1 + .../components/prose-shell/prose-shell.tsx | 30 + .../components/prose-page/constants.ts | 56 + .../(landing)/components/prose-page/index.ts | 6 + .../components/prose-page/prose-page.tsx | 44 + .../(landing)/components/prose-page/types.ts | 48 + .../(landing)/components/scroll-to-top.tsx | 22 - .../components/site-structured-data/index.ts | 1 + .../site-structured-data.tsx | 70 + .../solutions-page/components/index.ts | 4 + .../solutions-card-row/components/index.ts | 2 + .../components/solutions-card/index.ts | 1 + .../solutions-card/solutions-card.tsx | 42 + .../components/solutions-pill-cta/index.ts | 1 + .../solutions-pill-cta/solutions-pill-cta.tsx | 47 + .../components/solutions-card-row/index.ts | 1 + .../solutions-card-row/solutions-card-row.tsx | 72 + .../components/solutions-hero/index.ts | 1 + .../solutions-hero/solutions-hero.tsx | 58 + .../components/solutions-logos-row/index.ts | 1 + .../solutions-logos-row.tsx | 25 + .../solutions-structured-data/index.ts | 1 + .../solutions-structured-data.tsx | 72 + .../solutions-visual-frame/index.ts | 1 + .../solutions-visual-frame.tsx | 41 + .../components/solutions-page/constants.ts | 60 + .../components/solutions-page/index.ts | 8 + .../solutions-page/solutions-page.tsx | 63 + .../components/solutions-page/types.ts | 96 ++ .../(landing)/components/structured-data.tsx | 272 ---- .../templates/template-workflows.ts | 595 -------- .../components/templates/templates.tsx | 539 -------- .../components/testimonials/testimonials.tsx | 18 - .../components/thinking-loader/index.ts | 1 + .../thinking-loader.module.css | 382 ++++++ .../thinking-loader/thinking-loader.tsx | 449 ++++++ .../(landing)/components/trusted-by/index.ts | 1 + .../components/trusted-by/trusted-by.tsx | 23 + apps/sim/app/(landing)/contact/page.tsx | 51 - .../components/demo-booking/demo-booking.tsx | 102 ++ .../demo/components/demo-booking/index.ts | 1 + .../demo/components/demo-form/demo-form.tsx | 344 +++++ .../demo/components/demo-form/index.ts | 1 + .../demo-scheduler/demo-scheduler.tsx | 68 + .../demo/components/demo-scheduler/index.ts | 1 + apps/sim/app/(landing)/demo/demo.tsx | 71 + apps/sim/app/(landing)/demo/page.tsx | 61 + .../app/(landing)/enterprise/enterprise.tsx | 190 +++ apps/sim/app/(landing)/enterprise/page.tsx | 60 + .../components/integration-cta-button.tsx | 11 +- .../[slug]/components/integration-faq.tsx | 10 - .../integrations/(shell)/[slug]/loading.tsx | 4 +- .../(shell)/[slug]/opengraph-image.tsx | 2 +- .../integrations/(shell)/[slug]/page.tsx | 324 ++--- .../(landing)/integrations/(shell)/layout.tsx | 55 +- .../integrations/(shell)/opengraph-image.tsx | 4 +- .../(landing)/integrations/(shell)/page.tsx | 140 +- .../(landing)/integrations/[slug]/loading.tsx | 9 - .../components/integration-card.tsx | 28 +- .../components/integration-grid.tsx | 10 +- .../components/integration-icon.tsx | 11 +- .../components/request-integration-modal.tsx | 18 +- .../integrations/data/landing-content.ts | 6 +- .../integrations/data/seo-content.ts | 257 ++++ .../app/(landing)/integrations/data/types.ts | 70 +- .../app/(landing)/integrations/not-found.tsx | 19 +- apps/sim/app/(landing)/landing.tsx | 85 +- apps/sim/app/(landing)/layout.tsx | 42 +- .../(shell)/[provider]/[model]/loading.tsx | 4 +- .../(shell)/[provider]/[model]/page.tsx | 195 ++- .../models/(shell)/[provider]/loading.tsx | 4 +- .../models/(shell)/[provider]/page.tsx | 171 +-- .../app/(landing)/models/(shell)/layout.tsx | 50 +- .../sim/app/(landing)/models/(shell)/page.tsx | 136 +- .../models/[provider]/[model]/loading.tsx | 9 - .../(landing)/models/[provider]/loading.tsx | 9 - .../(landing)/models/components/constants.ts | 54 + .../app/(landing)/models/components/consts.ts | 9 - .../components/model-comparison-charts.tsx | 83 +- .../models/components/model-directory.tsx | 88 +- .../models/components/model-primitives.tsx | 258 +--- .../components/model-timeline-chart.tsx | 44 +- apps/sim/app/(landing)/models/not-found.tsx | 19 +- apps/sim/app/(landing)/models/utils.ts | 41 - apps/sim/app/{ => (landing)}/page.tsx | 26 +- apps/sim/app/(landing)/partners/page.tsx | 407 +++--- .../pricing/components/pricing-card/index.ts | 6 + .../components/pricing-card/pricing-card.tsx | 138 ++ .../pricing/components/pricing-plans/index.ts | 1 + .../pricing-plans/pricing-plans.tsx | 141 ++ .../pricing-structured-data/index.ts | 1 + .../pricing-structured-data.tsx | 100 ++ apps/sim/app/(landing)/pricing/page.tsx | 63 + apps/sim/app/(landing)/pricing/pricing.tsx | 59 + apps/sim/app/(landing)/privacy/loading.tsx | 59 - apps/sim/app/(landing)/privacy/page.tsx | 825 +---------- .../app/(landing)/privacy/privacy-content.tsx | 706 ++++++++++ apps/sim/app/(landing)/privacy/privacy.tsx | 12 + .../solutions/compliance/compliance.tsx | 77 ++ .../(landing)/solutions/compliance/page.tsx | 60 + .../solutions/engineering/engineering.tsx | 78 ++ .../(landing)/solutions/engineering/page.tsx | 60 + .../(landing)/solutions/finance/finance.tsx | 77 ++ .../app/(landing)/solutions/finance/page.tsx | 60 + apps/sim/app/(landing)/solutions/hr/hr.tsx | 78 ++ apps/sim/app/(landing)/solutions/hr/page.tsx | 60 + apps/sim/app/(landing)/solutions/it/it.tsx | 79 ++ apps/sim/app/(landing)/solutions/it/page.tsx | 60 + apps/sim/app/(landing)/terms/loading.tsx | 52 - apps/sim/app/(landing)/terms/page.tsx | 423 +----- .../sim/app/(landing)/terms/terms-content.tsx | 351 +++++ apps/sim/app/(landing)/terms/terms.tsx | 12 + apps/sim/app/(landing)/workflows/page.tsx | 63 + .../sim/app/(landing)/workflows/workflows.tsx | 92 ++ apps/sim/app/academy/(catalog)/layout.tsx | 20 +- apps/sim/app/api/contact/route.ts | 201 --- apps/sim/app/api/stars/route.ts | 44 +- .../components/changelog-content.tsx | 98 -- .../changelog/components/timeline-list.tsx | 233 ---- apps/sim/app/changelog/layout.tsx | 19 - apps/sim/app/changelog/loading.tsx | 38 - apps/sim/app/changelog/page.tsx | 18 - .../chat/components/auth/email/email-auth.tsx | 285 ---- .../auth/password/password-auth.tsx | 151 --- .../components/error-state/error-state.tsx | 21 - .../app/f/[token]/public-file-auth-shell.tsx | 2 +- apps/sim/app/invite/components/layout.tsx | 22 +- .../landing-preview/marks-lab/marks-lab.tsx | 966 +++++++++++++ .../app/landing-preview/marks-lab/page.tsx | 10 + apps/sim/app/landing-preview/page.tsx | 12 + apps/sim/app/not-found.tsx | 22 +- apps/sim/app/{(landing) => }/seo.test.ts | 3 +- apps/sim/app/sitemap.ts | 32 +- .../[block]/integration-block-detail.tsx | 6 +- .../integrations-showcase.tsx | 6 +- .../add-connector-modal.tsx | 8 +- .../connectors-section/connectors-section.tsx | 6 +- .../logs/components/log-details/utils.ts | 27 +- .../workflow-sidebar/workflow-sidebar.tsx | 5 +- .../comparison-table/comparison-data.ts | 4 +- .../output-select/output-select.tsx | 5 +- .../connection-blocks/connection-blocks.tsx | 4 +- .../credential-selector.tsx | 2 +- .../components/tag-dropdown/tag-dropdown.tsx | 5 +- .../components/tool-input/tool-input.tsx | 23 +- .../panel/components/editor/editor.tsx | 8 +- .../panel/components/toolbar/toolbar.tsx | 4 +- .../components/terminal/terminal.tsx | 13 +- .../preview-editor/preview-editor.tsx | 11 +- .../components/block/block.tsx | 5 +- .../components/subflow/subflow.tsx | 8 +- .../command-items/command-items.tsx | 3 +- apps/sim/blocks/icon-color.test.ts | 42 + apps/sim/blocks/icon-color.ts | 38 + apps/sim/components/icons.tsx | 42 +- apps/sim/ee/sso/components/sso-auth.tsx | 131 +- apps/sim/ee/sso/components/sso-form.tsx | 22 +- apps/sim/ee/whitelabeling/inject-theme.ts | 18 +- .../ee/whitelabeling/org-branding-utils.ts | 19 +- apps/sim/hooks/queries/demo-requests.ts | 27 + apps/sim/lib/api/contracts/contact.ts | 108 -- apps/sim/lib/api/contracts/demo-requests.ts | 2 + apps/sim/lib/api/contracts/index.ts | 1 - apps/sim/lib/colors/brightness.test.ts | 82 ++ apps/sim/lib/colors/brightness.ts | 74 + apps/sim/lib/colors/convert.test.ts | 48 + apps/sim/lib/colors/convert.ts | 100 ++ apps/sim/lib/colors/index.ts | 2 + apps/sim/lib/core/security/csp.ts | 8 + .../execution/sandbox/bundles/pptxgenjs.cjs | 40 +- apps/sim/lib/github/stars.ts | 50 + .../pptx-renderer/renderer/style-resolver.ts | 41 +- .../pptx-renderer/renderer/text-renderer.ts | 31 +- apps/sim/lib/pptx-renderer/utils/color.ts | 97 +- apps/sim/lib/workspaces/colors.ts | 15 +- apps/sim/package.json | 1 + apps/sim/public/landing/logos/artie.svg | 16 + apps/sim/public/landing/logos/exp-realty.svg | 61 + .../public/landing/logos/mobile-health.svg | 67 + apps/sim/public/landing/logos/rivian-vw.svg | 217 +++ .../landing/logos/russell-investments.svg | 22 + .../sim/public/landing/logos/thinkproject.svg | 1 + .../sim/public/landing/sim-feature-illo-1.svg | 1 + .../sim/public/landing/sim-feature-illo-2.svg | 1 + .../sim/public/landing/sim-feature-illo-4.svg | 1 + .../landing/sim-feature-illo-ingest.svg | 1 + apps/sim/public/landing/sim-mothership.webp | Bin 0 -> 87638 bytes apps/sim/tailwind.config.ts | 49 +- biome.json | 3 + bun.lock | 7 + package.json | 1 + .../emcn/src/components/chip-tag/chip-tag.tsx | 6 + .../emcn/src/components/chip/chip-chrome.ts | 9 + packages/emcn/src/components/index.ts | 1 + packages/emcn/src/icons/bubble-chat-delay.tsx | 39 + packages/emcn/src/icons/index.ts | 1 + .../src/lib/tile-icon-color.ts | 43 + .../src/subflow/subflow-node-view.tsx | 8 +- .../workflow-block/workflow-block-view.tsx | 8 +- scripts/check-api-validation-contracts.ts | 2 - scripts/check-bare-icons.ts | 146 ++ skills-lock.json | 17 + 435 files changed, 22116 insertions(+), 11984 deletions(-) create mode 100644 .agents/skills/design-taste-frontend/SKILL.md create mode 100644 .agents/skills/emil-design-eng/SKILL.md create mode 120000 .claude/skills/design-taste-frontend create mode 120000 .claude/skills/emil-design-eng create mode 100644 apps/sim/app/(auth)/components/auth-divider.tsx create mode 100644 apps/sim/app/(auth)/components/auth-field.tsx create mode 100644 apps/sim/app/(auth)/components/auth-form-message.tsx create mode 100644 apps/sim/app/(auth)/components/auth-header.tsx create mode 100644 apps/sim/app/(auth)/components/auth-input.tsx create mode 100644 apps/sim/app/(auth)/components/auth-legal-footer.tsx create mode 100644 apps/sim/app/(auth)/components/auth-nav-prompt.tsx create mode 100644 apps/sim/app/(auth)/components/auth-shell.tsx create mode 100644 apps/sim/app/(auth)/components/auth-submit-button.tsx create mode 100644 apps/sim/app/(auth)/components/auth-text-link.tsx create mode 100644 apps/sim/app/(auth)/components/constants.ts create mode 100644 apps/sim/app/(auth)/components/index.ts create mode 100644 apps/sim/app/(auth)/components/password-input.tsx delete mode 100644 apps/sim/app/(auth)/components/status-page-layout.tsx rename apps/sim/app/{ => (interfaces)}/chat/[identifier]/chat.tsx (98%) rename apps/sim/app/{ => (interfaces)}/chat/[identifier]/loading.tsx (100%) rename apps/sim/app/{ => (interfaces)}/chat/[identifier]/office-embed-init.tsx (100%) rename apps/sim/app/{ => (interfaces)}/chat/[identifier]/page.tsx (79%) create mode 100644 apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx create mode 100644 apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx create mode 100644 apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx rename apps/sim/app/{ => (interfaces)}/chat/components/header/header.tsx (100%) rename apps/sim/app/{ => (interfaces)}/chat/components/index.ts (100%) rename apps/sim/app/{ => (interfaces)}/chat/components/input/input.tsx (99%) rename apps/sim/app/{ => (interfaces)}/chat/components/input/voice-input.tsx (100%) rename apps/sim/app/{ => (interfaces)}/chat/components/loading-state/loading-state.tsx (100%) rename apps/sim/app/{ => (interfaces)}/chat/components/message-container/message-container.tsx (96%) rename apps/sim/app/{ => (interfaces)}/chat/components/message/components/file-download.test.tsx (94%) rename apps/sim/app/{ => (interfaces)}/chat/components/message/components/file-download.tsx (98%) rename apps/sim/app/{ => (interfaces)}/chat/components/message/components/markdown-renderer.tsx (100%) rename apps/sim/app/{ => (interfaces)}/chat/components/message/message.test.tsx (80%) rename apps/sim/app/{ => (interfaces)}/chat/components/message/message.tsx (98%) rename apps/sim/app/{ => (interfaces)}/chat/components/voice-interface/components/particles.tsx (100%) rename apps/sim/app/{ => (interfaces)}/chat/components/voice-interface/voice-interface.tsx (99%) rename apps/sim/app/{ => (interfaces)}/chat/constants.ts (100%) rename apps/sim/app/{ => (interfaces)}/chat/hooks/index.ts (100%) rename apps/sim/app/{ => (interfaces)}/chat/hooks/use-audio-streaming.ts (100%) rename apps/sim/app/{ => (interfaces)}/chat/hooks/use-chat-streaming.ts (98%) create mode 100644 apps/sim/app/(interfaces)/components/index.ts create mode 100644 apps/sim/app/(interfaces)/components/interfaces-shell/index.ts create mode 100644 apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx create mode 100644 apps/sim/app/(interfaces)/layout.tsx rename apps/sim/app/{ => (interfaces)}/resume/[workflowId]/[executionId]/[contextId]/page.tsx (100%) rename apps/sim/app/{ => (interfaces)}/resume/[workflowId]/[executionId]/loading.tsx (100%) rename apps/sim/app/{ => (interfaces)}/resume/[workflowId]/[executionId]/page.tsx (91%) rename apps/sim/app/{ => (interfaces)}/resume/[workflowId]/[executionId]/resume-page-client.tsx (99%) create mode 100644 apps/sim/app/(landing)/CLAUDE.md create mode 100644 apps/sim/app/(landing)/changelog/changelog.tsx create mode 100644 apps/sim/app/(landing)/changelog/components/changelog-actions/changelog-actions.tsx create mode 100644 apps/sim/app/(landing)/changelog/components/changelog-actions/index.ts create mode 100644 apps/sim/app/(landing)/changelog/components/changelog-timeline/changelog-timeline.tsx create mode 100644 apps/sim/app/(landing)/changelog/components/changelog-timeline/index.ts create mode 100644 apps/sim/app/(landing)/changelog/components/index.ts create mode 100644 apps/sim/app/(landing)/changelog/page.tsx create mode 100644 apps/sim/app/(landing)/changelog/types.ts create mode 100644 apps/sim/app/(landing)/changelog/utils.ts rename apps/sim/app/(landing)/{blog/[slug] => components/back-link}/back-link.tsx (56%) create mode 100644 apps/sim/app/(landing)/components/back-link/index.ts create mode 100644 apps/sim/app/(landing)/components/chevron-arrow/chevron-arrow.tsx create mode 100644 apps/sim/app/(landing)/components/chevron-arrow/index.ts delete mode 100644 apps/sim/app/(landing)/components/collaboration/collaboration.tsx delete mode 100644 apps/sim/app/(landing)/components/contact/contact-form.tsx create mode 100644 apps/sim/app/(landing)/components/cta/components/cta-chat.tsx create mode 100644 apps/sim/app/(landing)/components/cta/cta.tsx delete mode 100644 apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx delete mode 100644 apps/sim/app/(landing)/components/external-redirect.tsx create mode 100644 apps/sim/app/(landing)/components/features/components/feature-stage/feature-stage.tsx delete mode 100644 apps/sim/app/(landing)/components/features/components/features-preview.tsx create mode 100644 apps/sim/app/(landing)/components/features/components/formation-graph/formation-graph.tsx create mode 100644 apps/sim/app/(landing)/components/features/components/integrations-callout/integrations-callout.tsx create mode 100644 apps/sim/app/(landing)/components/features/components/knowledge-callout/knowledge-callout.tsx create mode 100644 apps/sim/app/(landing)/components/features/components/logs-table-preview/index.ts create mode 100644 apps/sim/app/(landing)/components/features/components/logs-table-preview/logs-table-preview.tsx create mode 100644 apps/sim/app/(landing)/components/features/index.ts delete mode 100644 apps/sim/app/(landing)/components/footer/footer-cta.tsx create mode 100644 apps/sim/app/(landing)/components/footer/index.ts delete mode 100644 apps/sim/app/(landing)/components/forms/landing-field.tsx create mode 100644 apps/sim/app/(landing)/components/hero-cta/hero-cta.tsx create mode 100644 apps/sim/app/(landing)/components/hero-cta/index.ts create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-visual/hero-visual.tsx create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-visual/stage-home.tsx create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-visual/stage-kb.tsx create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-visual/stage-workflow.tsx create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-visual/workflow-block.tsx create mode 100644 apps/sim/app/(landing)/components/hero/components/hero-visual/workflow-data.ts create mode 100644 apps/sim/app/(landing)/components/hero/components/index.ts create mode 100644 apps/sim/app/(landing)/components/hero/index.ts create mode 100644 apps/sim/app/(landing)/components/home-structured-data/home-structured-data.tsx create mode 100644 apps/sim/app/(landing)/components/home-structured-data/index.ts create mode 100644 apps/sim/app/(landing)/components/landing-faq/index.ts rename apps/sim/app/(landing)/components/{ => landing-faq}/landing-faq.tsx (84%) create mode 100644 apps/sim/app/(landing)/components/landing-preview/components/landing-preview-chat/chat-input.tsx create mode 100644 apps/sim/app/(landing)/components/landing-preview/components/landing-preview-chat/chat-title-bar.tsx create mode 100644 apps/sim/app/(landing)/components/landing-preview/components/landing-preview-chat/landing-preview-chat.tsx delete mode 100644 apps/sim/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel.tsx create mode 100644 apps/sim/app/(landing)/components/landing-preview/components/landing-preview-stage/landing-preview-stage-header.tsx create mode 100644 apps/sim/app/(landing)/components/landing-preview/hooks/use-landing-submit.ts create mode 100644 apps/sim/app/(landing)/components/landing-preview/landing-preview-mount.tsx create mode 100644 apps/sim/app/(landing)/components/landing-shell/index.ts create mode 100644 apps/sim/app/(landing)/components/landing-shell/landing-shell.tsx delete mode 100644 apps/sim/app/(landing)/components/legal-layout.tsx create mode 100644 apps/sim/app/(landing)/components/lifecycle/components/index.ts create mode 100644 apps/sim/app/(landing)/components/lifecycle/components/lifecycle-icons/index.ts create mode 100644 apps/sim/app/(landing)/components/lifecycle/components/lifecycle-icons/lifecycle-icons.tsx create mode 100644 apps/sim/app/(landing)/components/lifecycle/index.ts create mode 100644 apps/sim/app/(landing)/components/lifecycle/lifecycle.tsx create mode 100644 apps/sim/app/(landing)/components/logo-shell/index.ts create mode 100644 apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx create mode 100644 apps/sim/app/(landing)/components/logos/index.ts create mode 100644 apps/sim/app/(landing)/components/logos/logos.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/goo-marks/fan-rings.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/goo-marks/flower-of-life.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/goo-marks/goo-defs.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/goo-marks/goo-mark-svg.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/goo-marks/grid-9.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/goo-marks/index.ts create mode 100644 apps/sim/app/(landing)/components/mothership/components/goo-marks/lissajous-32.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/goo-marks/lissajous-morph.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/goo-marks/use-goo-hover.ts create mode 100644 apps/sim/app/(landing)/components/mothership/components/goo-marks/woven-torus.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/iso-marks/goo-defs.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/iso-marks/index.ts create mode 100644 apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-cube-grid.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-cube-row.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-four-box.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-grid-plane.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style.ts create mode 100644 apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-stacked-planes.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-star.tsx create mode 100644 apps/sim/app/(landing)/components/mothership/components/iso-marks/use-goo-mark.ts create mode 100644 apps/sim/app/(landing)/components/mothership/mothership.tsx delete mode 100644 apps/sim/app/(landing)/components/navbar/components/blog-dropdown.tsx delete mode 100644 apps/sim/app/(landing)/components/navbar/components/docs-dropdown.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/github-chip/github-chip.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/github-chip/index.ts delete mode 100644 apps/sim/app/(landing)/components/navbar/components/github-stars.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/logo-mark/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/logo-mark/logo-mark.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/mobile-nav/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-item/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-item/nav-menu-item.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/types.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/navbar-shell/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/components/sim-wordmark/index.ts create mode 100644 apps/sim/app/(landing)/components/navbar/components/sim-wordmark/sim-wordmark.tsx create mode 100644 apps/sim/app/(landing)/components/navbar/index.ts delete mode 100644 apps/sim/app/(landing)/components/not-found-view.tsx create mode 100644 apps/sim/app/(landing)/components/platform-page/components/index.ts create mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/index.ts create mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-card/index.ts create mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-card/platform-card.tsx create mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-pill-cta/index.ts create mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-pill-cta/platform-pill-cta.tsx create mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-card-row/index.ts create mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-card-row/platform-card-row.tsx create mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-hero/index.ts create mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-hero/platform-hero.tsx create mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-logos-row/index.ts create mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-logos-row/platform-logos-row.tsx create mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-structured-data/index.ts create mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-structured-data/platform-structured-data.tsx create mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-visual-frame/index.ts create mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-visual-frame/platform-visual-frame.tsx create mode 100644 apps/sim/app/(landing)/components/platform-page/constants.ts create mode 100644 apps/sim/app/(landing)/components/platform-page/index.ts create mode 100644 apps/sim/app/(landing)/components/platform-page/platform-page.tsx create mode 100644 apps/sim/app/(landing)/components/platform-page/types.ts delete mode 100644 apps/sim/app/(landing)/components/pricing/pricing.tsx create mode 100644 apps/sim/app/(landing)/components/prose-page/components/index.ts create mode 100644 apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/index.ts create mode 100644 apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/index.ts create mode 100644 apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx create mode 100644 apps/sim/app/(landing)/components/prose-page/components/legal-block-group/index.ts create mode 100644 apps/sim/app/(landing)/components/prose-page/components/legal-block-group/legal-block-group.tsx create mode 100644 apps/sim/app/(landing)/components/prose-page/components/legal-section/index.ts create mode 100644 apps/sim/app/(landing)/components/prose-page/components/legal-section/legal-section.tsx create mode 100644 apps/sim/app/(landing)/components/prose-page/components/prose-hero/index.ts create mode 100644 apps/sim/app/(landing)/components/prose-page/components/prose-hero/prose-hero.tsx create mode 100644 apps/sim/app/(landing)/components/prose-page/components/prose-link/index.ts create mode 100644 apps/sim/app/(landing)/components/prose-page/components/prose-link/prose-link.tsx create mode 100644 apps/sim/app/(landing)/components/prose-page/components/prose-shell/index.ts create mode 100644 apps/sim/app/(landing)/components/prose-page/components/prose-shell/prose-shell.tsx create mode 100644 apps/sim/app/(landing)/components/prose-page/constants.ts create mode 100644 apps/sim/app/(landing)/components/prose-page/index.ts create mode 100644 apps/sim/app/(landing)/components/prose-page/prose-page.tsx create mode 100644 apps/sim/app/(landing)/components/prose-page/types.ts delete mode 100644 apps/sim/app/(landing)/components/scroll-to-top.tsx create mode 100644 apps/sim/app/(landing)/components/site-structured-data/index.ts create mode 100644 apps/sim/app/(landing)/components/site-structured-data/site-structured-data.tsx create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/index.ts create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/index.ts create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card/index.ts create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card/solutions-card.tsx create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-pill-cta/index.ts create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-pill-cta/solutions-pill-cta.tsx create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/index.ts create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/solutions-card-row.tsx create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/solutions-hero/index.ts create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/solutions-hero/solutions-hero.tsx create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/solutions-logos-row/index.ts create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/solutions-logos-row/solutions-logos-row.tsx create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/solutions-structured-data/index.ts create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/solutions-structured-data/solutions-structured-data.tsx create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/solutions-visual-frame/index.ts create mode 100644 apps/sim/app/(landing)/components/solutions-page/components/solutions-visual-frame/solutions-visual-frame.tsx create mode 100644 apps/sim/app/(landing)/components/solutions-page/constants.ts create mode 100644 apps/sim/app/(landing)/components/solutions-page/index.ts create mode 100644 apps/sim/app/(landing)/components/solutions-page/solutions-page.tsx create mode 100644 apps/sim/app/(landing)/components/solutions-page/types.ts delete mode 100644 apps/sim/app/(landing)/components/structured-data.tsx delete mode 100644 apps/sim/app/(landing)/components/templates/template-workflows.ts delete mode 100644 apps/sim/app/(landing)/components/templates/templates.tsx delete mode 100644 apps/sim/app/(landing)/components/testimonials/testimonials.tsx create mode 100644 apps/sim/app/(landing)/components/thinking-loader/index.ts create mode 100644 apps/sim/app/(landing)/components/thinking-loader/thinking-loader.module.css create mode 100644 apps/sim/app/(landing)/components/thinking-loader/thinking-loader.tsx create mode 100644 apps/sim/app/(landing)/components/trusted-by/index.ts create mode 100644 apps/sim/app/(landing)/components/trusted-by/trusted-by.tsx delete mode 100644 apps/sim/app/(landing)/contact/page.tsx create mode 100644 apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx create mode 100644 apps/sim/app/(landing)/demo/components/demo-booking/index.ts create mode 100644 apps/sim/app/(landing)/demo/components/demo-form/demo-form.tsx create mode 100644 apps/sim/app/(landing)/demo/components/demo-form/index.ts create mode 100644 apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx create mode 100644 apps/sim/app/(landing)/demo/components/demo-scheduler/index.ts create mode 100644 apps/sim/app/(landing)/demo/demo.tsx create mode 100644 apps/sim/app/(landing)/demo/page.tsx create mode 100644 apps/sim/app/(landing)/enterprise/enterprise.tsx create mode 100644 apps/sim/app/(landing)/enterprise/page.tsx delete mode 100644 apps/sim/app/(landing)/integrations/(shell)/[slug]/components/integration-faq.tsx delete mode 100644 apps/sim/app/(landing)/integrations/[slug]/loading.tsx create mode 100644 apps/sim/app/(landing)/integrations/data/seo-content.ts delete mode 100644 apps/sim/app/(landing)/models/[provider]/[model]/loading.tsx delete mode 100644 apps/sim/app/(landing)/models/[provider]/loading.tsx create mode 100644 apps/sim/app/(landing)/models/components/constants.ts delete mode 100644 apps/sim/app/(landing)/models/components/consts.ts rename apps/sim/app/{ => (landing)}/page.tsx (62%) create mode 100644 apps/sim/app/(landing)/pricing/components/pricing-card/index.ts create mode 100644 apps/sim/app/(landing)/pricing/components/pricing-card/pricing-card.tsx create mode 100644 apps/sim/app/(landing)/pricing/components/pricing-plans/index.ts create mode 100644 apps/sim/app/(landing)/pricing/components/pricing-plans/pricing-plans.tsx create mode 100644 apps/sim/app/(landing)/pricing/components/pricing-structured-data/index.ts create mode 100644 apps/sim/app/(landing)/pricing/components/pricing-structured-data/pricing-structured-data.tsx create mode 100644 apps/sim/app/(landing)/pricing/page.tsx create mode 100644 apps/sim/app/(landing)/pricing/pricing.tsx delete mode 100644 apps/sim/app/(landing)/privacy/loading.tsx create mode 100644 apps/sim/app/(landing)/privacy/privacy-content.tsx create mode 100644 apps/sim/app/(landing)/privacy/privacy.tsx create mode 100644 apps/sim/app/(landing)/solutions/compliance/compliance.tsx create mode 100644 apps/sim/app/(landing)/solutions/compliance/page.tsx create mode 100644 apps/sim/app/(landing)/solutions/engineering/engineering.tsx create mode 100644 apps/sim/app/(landing)/solutions/engineering/page.tsx create mode 100644 apps/sim/app/(landing)/solutions/finance/finance.tsx create mode 100644 apps/sim/app/(landing)/solutions/finance/page.tsx create mode 100644 apps/sim/app/(landing)/solutions/hr/hr.tsx create mode 100644 apps/sim/app/(landing)/solutions/hr/page.tsx create mode 100644 apps/sim/app/(landing)/solutions/it/it.tsx create mode 100644 apps/sim/app/(landing)/solutions/it/page.tsx delete mode 100644 apps/sim/app/(landing)/terms/loading.tsx create mode 100644 apps/sim/app/(landing)/terms/terms-content.tsx create mode 100644 apps/sim/app/(landing)/terms/terms.tsx create mode 100644 apps/sim/app/(landing)/workflows/page.tsx create mode 100644 apps/sim/app/(landing)/workflows/workflows.tsx delete mode 100644 apps/sim/app/api/contact/route.ts delete mode 100644 apps/sim/app/changelog/components/changelog-content.tsx delete mode 100644 apps/sim/app/changelog/components/timeline-list.tsx delete mode 100644 apps/sim/app/changelog/layout.tsx delete mode 100644 apps/sim/app/changelog/loading.tsx delete mode 100644 apps/sim/app/changelog/page.tsx delete mode 100644 apps/sim/app/chat/components/auth/email/email-auth.tsx delete mode 100644 apps/sim/app/chat/components/auth/password/password-auth.tsx delete mode 100644 apps/sim/app/chat/components/error-state/error-state.tsx create mode 100644 apps/sim/app/landing-preview/marks-lab/marks-lab.tsx create mode 100644 apps/sim/app/landing-preview/marks-lab/page.tsx create mode 100644 apps/sim/app/landing-preview/page.tsx rename apps/sim/app/{(landing) => }/seo.test.ts (98%) create mode 100644 apps/sim/blocks/icon-color.test.ts create mode 100644 apps/sim/hooks/queries/demo-requests.ts delete mode 100644 apps/sim/lib/api/contracts/contact.ts create mode 100644 apps/sim/lib/colors/brightness.test.ts create mode 100644 apps/sim/lib/colors/brightness.ts create mode 100644 apps/sim/lib/colors/convert.test.ts create mode 100644 apps/sim/lib/colors/convert.ts create mode 100644 apps/sim/lib/colors/index.ts create mode 100644 apps/sim/lib/github/stars.ts create mode 100644 apps/sim/public/landing/logos/artie.svg create mode 100644 apps/sim/public/landing/logos/exp-realty.svg create mode 100644 apps/sim/public/landing/logos/mobile-health.svg create mode 100644 apps/sim/public/landing/logos/rivian-vw.svg create mode 100644 apps/sim/public/landing/logos/russell-investments.svg create mode 100644 apps/sim/public/landing/logos/thinkproject.svg create mode 100644 apps/sim/public/landing/sim-feature-illo-1.svg create mode 100644 apps/sim/public/landing/sim-feature-illo-2.svg create mode 100644 apps/sim/public/landing/sim-feature-illo-4.svg create mode 100644 apps/sim/public/landing/sim-feature-illo-ingest.svg create mode 100644 apps/sim/public/landing/sim-mothership.webp create mode 100644 packages/emcn/src/icons/bubble-chat-delay.tsx create mode 100644 packages/workflow-renderer/src/lib/tile-icon-color.ts create mode 100644 scripts/check-bare-icons.ts create mode 100644 skills-lock.json diff --git a/.agents/skills/design-taste-frontend/SKILL.md b/.agents/skills/design-taste-frontend/SKILL.md new file mode 100644 index 00000000000..b72132fcd46 --- /dev/null +++ b/.agents/skills/design-taste-frontend/SKILL.md @@ -0,0 +1,1206 @@ +--- +name: design-taste-frontend +description: Anti-slop frontend skill for landing pages, portfolios, and redesigns. The agent reads the brief, infers the right design direction, and ships interfaces that do not look templated. Real design systems when applicable, audit-first on redesigns, strict pre-flight check. +--- + +# tasteskill: Anti-Slop Frontend Skill + +> Landing pages, portfolios, and redesigns. Not dashboards, not data tables, not multi-step product UI. +> Every rule below is **contextual**. None of it fires automatically. First read the brief, then pull only what fits. + +--- + +## 0. BRIEF INFERENCE (Read the Room Before Anything Else) + +Before touching code or tweaking dials, **infer what the user actually wants**. Most LLM design output is bad because the model jumps to a default aesthetic instead of reading the room. + +### 0.A Read these signals first +1. **Page kind** - landing (SaaS / consumer / agency / event), portfolio (dev / designer / creative studio), redesign (preserve vs overhaul), editorial / blog. +2. **Vibe words** the user used - "minimalist", "calm", "Linear-style", "Awwwards", "brutalist", "premium consumer", "Apple-y", "playful", "serious B2B", "editorial", "agency-y", "glassy", "dark tech". +3. **Reference signals** - URLs they linked, screenshots they pasted, products they named, brands they're competing with. +4. **Audience** - B2B procurement panel vs. design-conscious consumer vs. recruiter scanning a portfolio. The audience picks the aesthetic, not your taste. +5. **Brand assets that already exist** - logo, color, type, photography. For redesigns, these are starting material, not optional input (see Section 11). +6. **Quiet constraints** - accessibility-first audiences, public-sector, regulated industries, trust-first commerce, kids' products. These constraints OVERRIDE aesthetic preference. + +### 0.B Output a one-line "Design Read" before generating +Before any code, state in one line: **"Reading this as: \ for \, with a \ language, leaning toward \."** + +Example reads: +- *"Reading this as: B2B SaaS landing for technical buyers, with a Linear-style minimalist language, leaning toward Tailwind utilities + Geist + restrained motion."* +- *"Reading this as: solo designer portfolio for hiring managers, with an editorial / kinetic-type language, leaning toward native CSS + scroll-driven animation + custom typography."* +- *"Reading this as: redesign of a public-sector service site, with a trust-first language, leaning toward GOV.UK Frontend or USWDS."* + +### 0.C If the brief is ambiguous, ask one question, do not guess +Ask exactly **one** clarifying question - never a multi-question dump - and only when the design read genuinely diverges. Example: *"Should this feel closer to Linear-clean or Awwwards-experimental?"* + +If you can confidently infer from context, **do not ask**. Just declare the design read and proceed. + +### 0.D Anti-Default Discipline +Do not default to: AI-purple gradients, centered hero over dark mesh, three equal feature cards, generic glassmorphism on everything, infinite-loop micro-animations everywhere, Inter + slate-900. These are the LLM defaults. Reach past them deliberately based on the design read. + +--- + +## 1. THE THREE DIALS (Core Configuration) + +After the design read, set three dials. Every layout, motion, and density decision below is gated by these. + +* **`DESIGN_VARIANCE: 8`** - 1 = Perfect Symmetry, 10 = Artsy Chaos +* **`MOTION_INTENSITY: 6`** - 1 = Static, 10 = Cinematic / Physics +* **`VISUAL_DENSITY: 4`** - 1 = Art Gallery / Airy, 10 = Cockpit / Packed Data + +**Baseline:** `8 / 6 / 4`. Use these unless the design read overrides them. Do not ask the user to edit this file - overrides happen conversationally. + +### 1.A Dial Inference (design read → dial values) +| Signal | VARIANCE | MOTION | DENSITY | +|---|---|---|---| +| "minimalist / clean / calm / editorial / Linear-style" | 5-6 | 3-4 | 2-3 | +| "premium consumer / Apple-y / luxury / brand" | 7-8 | 5-7 | 3-4 | +| "playful / wild / Dribbble / Awwwards / experimental / agency" | 9-10 | 8-10 | 3-4 | +| "landing page / portfolio / marketing site (default)" | 7-9 | 6-8 | 3-5 | +| "trust-first / public-sector / regulated / accessibility-critical" | 3-4 | 2-3 | 4-5 | +| "redesign - preserve" | match existing | +1 | match existing | +| "redesign - overhaul" | +2 | +2 | match existing | + +### 1.B Use-Case Presets +| Use case | VARIANCE | MOTION | DENSITY | +|---|---|---|---| +| Landing (SaaS, mainstream) | 7 | 6 | 4 | +| Landing (Agency / creative) | 9 | 8 | 3 | +| Landing (Premium consumer) | 7 | 6 | 3 | +| Portfolio (Designer / studio) | 8 | 7 | 3 | +| Portfolio (Developer) | 6 | 5 | 4 | +| Editorial / Blog | 6 | 4 | 3 | +| Public-sector service | 3 | 2 | 5 | +| Redesign - preserve | match | match+1 | match | +| Redesign - overhaul | +2 | +2 | match | + +### 1.C How the Dials Drive Output +Use these (or user-overridden values) as global variables. Cross-references throughout this document refer to these exact variable names - never invent aliases like `LAYOUT_VARIANCE` or `ANIM_LEVEL`. + +--- + +## 2. BRIEF → DESIGN SYSTEM MAP + +Once you have the design read (Section 0) and dials (Section 1), pick the right foundation. Do not invent CSS for things that have an official package. Do not pretend an aesthetic trend is an official system. + +### 2.A When to reach for a real design system (use official packages) +| Brief reads as… | Reach for | Why | +|---|---|---| +| Microsoft / enterprise SaaS / dashboards | `@fluentui/react-components` or `@fluentui/web-components` | Official Fluent UI, Microsoft tokens, accessibility done | +| Google-ish UI, Material-flavored product | `@material/web` + Material 3 tokens | Official, theme-able via Material Theming | +| IBM-style B2B / enterprise analytics | `@carbon/react` + `@carbon/styles` | Official Carbon, mature data-density patterns | +| Shopify app surfaces | `polaris.js` web components / Polaris React | Required for Shopify admin UI | +| Atlassian / Jira-style product | `@atlaskit/*` + `@atlaskit/tokens` | Official Atlassian DS | +| GitHub-style devtool / community page | `@primer/css` or `@primer/react-brand` | Official Primer; Brand variant for marketing | +| Public-sector UK service | `govuk-frontend` | Legally / regulatorily expected | +| US public-sector / trust-first | `uswds` | Same | +| Fast local-business / agency MVP | Bootstrap 5.3 | Boring, fast, works | +| Modern accessible React foundation | `@radix-ui/themes` | Primitives + polished theme | +| Modern SaaS where you own the components | shadcn/ui (`npx shadcn@latest add ...`) | You own the code, easy to customise; never ship default state | +| Tailwind-based modern SaaS / AI marketing | Tailwind v4 utilities + `dark:` variant | Default for indie + small team builds | + +**Honesty rule:** if the brief reads as one of the systems above, install and use the **official** package. Do not recreate its CSS by hand. Do not import a system's tokens but then override 90% of them. + +**One system per project.** Do not mix Fluent React with Carbon in the same tree. Do not import shadcn/ui components into a Material 3 app. + +### 2.B When the brief is an aesthetic, not a system +For these directions, there is **no single official package**. Build with native CSS + Tailwind + a maintained component library. Be honest in code comments about what is borrowed inspiration vs. official material. + +| Aesthetic | Honest implementation | +|---|---| +| Glassmorphism / "frosted glass" | `backdrop-filter`, layered borders, highlight overlays. Provide solid-fill fallback for `prefers-reduced-transparency`. | +| Bento (Apple-style tile grids) | CSS Grid with mixed cell sizes. No single library owns this. | +| Brutalism | Native CSS, monospace, raw borders. No library. | +| Editorial / magazine | Serif type, asymmetric grid, generous whitespace. No library. | +| Dark tech / hacker | Mono + accent neon, terminal motifs. No library. | +| Aurora / mesh gradients | SVG or layered radial gradients. No library. | +| Kinetic typography | Native CSS animations, scroll-driven animations, GSAP for hijacks. No library. | +| **Apple Liquid Glass** | Apple documents this for Apple platforms only. **There is no official `liquid-glass.css`.** Web implementations are approximations using `backdrop-filter` + layered borders + highlights. Label clearly as approximation. | + +--- + +## 3. DEFAULT ARCHITECTURE & CONVENTIONS + +Unless the design read picks a real design system (Section 2.A), these are the defaults: + +### 3.A Stack +* **Framework:** React or Next.js. Default to Server Components (RSC). + * **RSC SAFETY:** Global state works ONLY in Client Components. In Next.js, wrap providers in a `"use client"` component. + * **INTERACTIVITY ISOLATION:** Any component using Motion, scroll listeners, or pointer physics MUST be an isolated leaf with `'use client'` at the top. Server Components render static layouts only. +* **Styling:** **Tailwind v4** (default). Tailwind v3 only if the existing project demands it. + * For v4: do NOT use `tailwindcss` plugin in `postcss.config.js`. Use `@tailwindcss/postcss` or the Vite plugin. +* **Animation:** **Motion** (the library formerly known as Framer Motion). Import from `motion/react` (`import { motion } from "motion/react"`). The `framer-motion` package still works as a legacy alias - prefer `motion/react` in new code. +* **Fonts:** Always use `next/font` (Next.js) or self-host with `@font-face` + `font-display: swap`. Never link Google Fonts via `` in production. + +### 3.B State +* Local `useState` / `useReducer` for isolated UI. +* Global state ONLY for deep prop-drilling avoidance - Zustand, Jotai, or React context. +* **NEVER** use `useState` to track continuous values driven by user input (mouse position, scroll progress, pointer physics, magnetic hover). Use Motion's `useMotionValue` / `useTransform` / `useScroll`. `useState` re-renders the React tree on every change and collapses on mobile. + +### 3.C Icons +* **Allowed libraries (priority order):** `@phosphor-icons/react`, `hugeicons-react`, `@radix-ui/react-icons`, `@tabler/icons-react`. +* **Discouraged:** `lucide-react`. Acceptable only when the user explicitly asks for it or the project already depends on it. +* **NEVER hand-roll SVG icons.** If a glyph is missing, install a second library or compose from primitives - do not draw icon paths from scratch. +* **One family per project.** Do not mix Phosphor with Lucide in the same component tree. +* **Standardize `strokeWidth` globally** (e.g. `1.5` or `2.0`). + +### 3.D Emoji Policy +Discouraged by default in code, markup, and visible text. Replace symbols with icon-library glyphs. **Override:** allow emojis only when the user explicitly asks for a playful / chat-style / social-native vibe - and even then use them sparingly with intent. + +### 3.E Responsiveness & Layout Mechanics +* Standardize breakpoints (`sm 640`, `md 768`, `lg 1024`, `xl 1280`, `2xl 1536`). +* Contain page layouts using `max-w-[1400px] mx-auto` or `max-w-7xl`. +* **Viewport Stability:** NEVER use `h-screen` for full-height Hero sections. ALWAYS use `min-h-[100dvh]` to prevent layout jumping on mobile (iOS Safari address bar). +* **Grid over Flex-Math:** NEVER use complex flexbox percentage math (`w-[calc(33%-1rem)]`). ALWAYS use CSS Grid (`grid grid-cols-1 md:grid-cols-3 gap-6`). + +### 3.F Dependency Verification (mandatory) +Before importing ANY 3rd-party library, check `package.json`. If the package is missing, output the install command first. **Never** assume a library exists. + +--- + +## 4. DESIGN ENGINEERING DIRECTIVES (Bias Correction) + +LLMs default to clichés. Override these defaults proactively. Each rule has a context-aware override path. + +### 4.1 Typography +* **Display / Headlines:** Default `text-4xl md:text-6xl tracking-tighter leading-none`. +* **Body / Paragraphs:** Default `text-base text-gray-600 leading-relaxed max-w-[65ch]`. +* **Sans font choice:** + * **Discouraged as default:** `Inter`. Pick `Geist`, `Outfit`, `Cabinet Grotesk`, `Satoshi`, or a brand-appropriate serif first. + * **Override:** Inter is acceptable when the user explicitly asks for a neutral / standard / Linear-style feel, or when the brief is a public-sector / accessibility-first site. +* **Pairings to know:** `Geist` + `Geist Mono`, `Satoshi` + `JetBrains Mono`, `Cabinet Grotesk` + `Inter Tight`, `GT America` + `IBM Plex Mono`. + +* **SERIF DISCIPLINE (VERY DISCOURAGED AS DEFAULT):** + * Serif is **very discouraged as the default font for any project.** "It feels creative / premium / editorial" is NOT a reason to reach for serif. The agent's default mental model that "creative brief = serif" is the single most-tested AI tell in production rounds. + * **Serif is only acceptable when ONE of these is explicitly true:** + - The brand brief literally names a serif font, OR + - The aesthetic family is genuinely editorial / luxury / publication / manuscript / heritage / vintage AND you can articulate why this specific serif fits this specific brand + * For everything else (creative agency, design studio, modern brand, premium consumer, portfolio, lifestyle), **default sans-serif display** (Geist Display, ABC Diatype, Söhne Breit, Cabinet Grotesk Display, Migra Sans, GT Walsheim, Inter Display, PP Neue Montreal). Sans display fonts are not "boring" — they are the default for the same reason black is the default in fashion. + * **EMPHASIS RULE (related):** When you want to emphasize a word within a headline (the kinetic "and `spatial` design" type move), use **italic or bold of the SAME font**. Do NOT inject a random serif word into a sans headline (or vice versa) just to add visual interest. Mixed-family emphasis is amateur. Italic/bold emphasis in the same family is the right move. + * **Specifically BANNED as defaults:** `Fraunces` and `Instrument_Serif` (the two LLM-favorite display serifs). + * **If a serif is justified** (rare, per the above), rotate from this pool, do NOT reuse the same serif across consecutive projects: PP Editorial New, GT Sectra Display, Cardinal Grotesque, Reckless Neue, Tiempos Headline, Recoleta, Cormorant Garamond, Playfair Display, EB Garamond, IvyPresto, Migra, Editorial Old, Saol Display, Söhne Breit Kursiv, Domaine Display, Canela, Schnyder, Tobias, NB Architekt, ITC Galliard. + +* **ITALIC DESCENDER CLEARANCE (mandatory):** When italic is used in display type and the word contains a descender letter (`y g j p q`), `leading-[1]` or `leading-none` will clip the descender. Use `leading-[1.1]` minimum and add `pb-1` or `mb-1` reserve on the wrapping element. Audit every italic word in display headlines before shipping. + +### 4.2 Color Calibration +* Max 1 accent color. Saturation < 80% by default. +* **THE LILA RULE:** The "AI Purple / Blue glow" aesthetic is discouraged as a default. No automatic purple button glows, no random neon gradients. Use neutral bases (Zinc / Slate / Stone) with high-contrast singular accents (Emerald, Electric Blue, Deep Rose, Burnt Orange, etc.). +* **Override:** if the brand or brief explicitly asks for purple / violet / lila, embrace it. But execute with intent: consistent palette, harmonised neutrals, restrained gradients. Not generic AI gradient slop. +* **One palette per project.** Do not fluctuate between warm and cool grays within the same project. +* **COLOR CONSISTENCY LOCK (mandatory):** Once an accent color is chosen for a page, it is used on the WHOLE page. A warm-grey site does not suddenly get a blue CTA in section 7. A rose-accented site does not get a teal status badge in the footer. Pick one accent, lock it, audit every component before shipping. + +* **PREMIUM-CONSUMER PALETTE BAN (mandatory, second-most-recurring AI-tell):** + * For premium-consumer briefs (cookware, wellness, artisan, luxury, heritage craft, DTC home goods, etc.) the LLM default is **warm beige/cream + brass/clay/oxblood/ochre + espresso/ink dark text**. Concretely banned hex families as default backgrounds and accents: + - Backgrounds: `#f5f1ea`, `#f7f5f1`, `#fbf8f1`, `#efeae0`, `#ece6db`, `#faf7f1`, `#e8dfcb` (all "warm paper / cream / chalk / bone") + - Accents: `#b08947`, `#b6553a`, `#9a2436`, `#9c6e2a`, `#bc7c3a`, `#7d5621` (all "brass / clay / oxblood / ochre") + - Text: `#1a1714`, `#1a1814`, `#1b1814` (all "espresso / warm near-black") + * This palette is BANNED as the default reach for premium-consumer briefs. Every premium-consumer site you have ever shipped uses this exact palette. The brand becomes invisible. + * **Default alternatives (rotate, do not reuse):** + - **Cold Luxury:** silver-grey + chrome + smoke (think Tesla, Apple Watch Hermes-without-the-leather) + - **Forest:** deep green + bone + amber accent (think Filson, Patagonia premium) + - **Black and Tan:** true off-black + warm tan, sharp contrast, no beige + - **Cobalt + Cream:** saturated blue against a single neutral, no brass + - **Terracotta + Slate:** warm rust against cool grey, no brass + - **Olive + Brick + Paper:** muted olive plus brick-red accent + - **Pure monochrome + single saturated pop:** off-white + off-black + one bright accent (electric blue, emerald, hot pink, etc.) + * **Palette-rotation rule:** if the previous premium-consumer project you generated used the beige+brass family, this one MUST use a different family. Do not ship the same warm-craft palette twice in a row. + * **Override:** the beige+brass+espresso palette is acceptable ONLY when the brand brief explicitly names those colors, or when the brand identity is genuinely vintage / artisan / warm-craft AND you can articulate why this specific palette fits this specific brand. Default-reaching for it because "this is a cookware brief" is banned. + +### 4.3 Layout Diversification +* **ANTI-CENTER BIAS:** Centered Hero / H1 sections are avoided when `DESIGN_VARIANCE > 4`. Force "Split Screen" (50/50), "Left-aligned content / right-aligned asset", "Asymmetric white-space", or scroll-pinned structures. +* **Override:** centered hero is OK for editorial / manifesto / launch-announcement briefs where the message itself is the design. + +### 4.4 Materiality, Shadows, Cards +* Use cards ONLY when elevation communicates real hierarchy. Otherwise group with `border-t`, `divide-y`, or negative space. +* When a shadow is used, tint it to the background hue. No pure-black drop shadows on light backgrounds. +* For `VISUAL_DENSITY > 7`: generic card containers are banned. Data metrics breathe in plain layout. +* **SHAPE CONSISTENCY LOCK (mandatory):** Pick ONE corner-radius scale for the page and stick to it. Options: all-sharp (radius 0), all-soft (radius 12-16px), all-pill (full radius for interactive). Mixed systems are allowed only when there is a documented rule (e.g. "buttons are full-pill, cards are 16px, inputs are 8px") and that rule is followed everywhere. Round buttons in a square layout, or square cards on a pill-button page, is broken design. + +### 4.5 Interactive UI States +LLMs default to "static successful state only." Always implement full cycles: +* **Loading:** Skeletal loaders matching the final layout's shape. Avoid generic circular spinners. +* **Empty States:** Beautifully composed; indicate how to populate. +* **Error States:** Clear, inline (forms), or contextual (toasts only for transient). +* **Tactile Feedback:** On `:active`, use `-translate-y-[1px]` or `scale-[0.98]` to simulate a physical push. +* **BUTTON CONTRAST CHECK (mandatory, a11y):** Before shipping any button, verify the button text is readable against the button background. White button + white text, `bg-white` CTA with `text-white` label, transparent button against the page background with no border → all banned. Audit every CTA: contrast ratio WCAG AA min (4.5:1 for body, 3:1 for large text 18px+). Same rule applies to ghost buttons over photographic backgrounds (use a backdrop, scrim, or stroke). +* **CTA BUTTON WRAP BAN (mandatory):** Button text MUST fit on one line at desktop. If a label like "VIEW SELECTED WORK" wraps to 2 or 3 lines, the button is broken. Fix by EITHER shortening the label (3 words max for primary CTAs, ideally 1-2) OR widening the button (do not artificially constrain `max-width` on CTAs). Wrapped CTAs at desktop are a Pre-Flight Fail. +* **NO DUPLICATE CTA INTENT (mandatory):** Two CTAs with the same intent on one page is a Pre-Flight Fail. Examples of same intent: "Get in touch" + "Contact us" + "Let's talk" + "Start a project" + "Start something" + "Reach out" = all "contact" intent → pick ONE label and use it everywhere on the page (nav, hero, footer). Same for "Try free" + "Get started" + "Sign up free" (all "signup" intent) and "View work" + "See selected work" + "Browse projects" (all "portfolio" intent). One label per intent. +* **FORM CONTRAST CHECK (mandatory, a11y):** Form inputs, placeholder text, focus rings, helper text, and error text all pass WCAG AA contrast against the section background. Light placeholders on a near-white form, white form on white page section, form labels grayer than 4.5:1 contrast → all banned. Audit every form before shipping. + +### 4.6 Data & Form Patterns +* Label ABOVE input. Helper text optional but present in markup. Error text BELOW input. Standard `gap-2` for input blocks. +* No placeholder-as-label. Ever. + +### 4.7 Layout Discipline (Hard Rules. Failing any of these is shipping broken work) + +* **Hero MUST fit in the initial viewport.** Headline max 2 lines on desktop, subtext max **20 words** AND max 3-4 lines, CTAs visible without scroll. If the copy is too long: reduce font scale OR cut copy. If you cannot describe the value-prop in 20 words of subtext, the value-prop is unclear, not the rule too tight. Never let the hero overflow and force scroll to find the CTA. +* **Hero font-scale discipline.** Plan font size and image size *together*. If the hero asset is large and the headline is more than 6 words, do not start at `text-7xl/text-8xl`. Default sensible range: `text-4xl md:text-5xl lg:text-6xl` for most heroes; `text-6xl md:text-7xl` only when the headline is 3-5 words. A 4-line hero headline is always a font-size error, never a copy-length error. +* **HERO TOP PADDING CAP (mandatory):** Hero top padding max `pt-24` (≈6rem) at desktop. More than that means the hero content floats halfway down the viewport and reads as a layout bug, not as intentional space. If your hero needs more breathing room, increase font scale or asset size, not top padding. +* **HERO STACK DISCIPLINE (max 4 text elements).** The hero is a single moment, not a feature list. Allowed text elements, max 4 in total: + 1. Eyebrow (small uppercase label) OR brand strip OR neither - pick zero or one + 2. Headline (max 2 lines, see above) + 3. Subtext (max 20 words, max 4 lines) + 4. CTAs (1 primary + max 1 secondary) + - **BANNED in the hero:** tiny tagline below CTAs ("Works with GitHub, GitLab, and self-hosted Git"), trust micro-strip ("Used by engineering teams at..."), pricing teaser ("Free for solo, $10/user for teams"), feature bullet list, social-proof avatar row. All of those move to dedicated sections directly below the hero. + - If you have an eyebrow AND a tagline below CTAs in the same hero, drop the tagline. If you have a brand strip AND a tagline, drop the tagline. One small text element per hero, max. +* **"Used by" / "Trusted by" logo wall belongs UNDER the hero, never inside it.** The hero is for the value prop and primary CTA. The logo wall is a separate section directly below. Do not stuff trust logos into the same flex row as the hero copy. +* **Navigation MUST render on a single line on desktop.** If items don't fit at `lg` (1024px), condense labels, drop secondary items, or move to a hamburger. A two-line nav at desktop is broken design. +* **Navigation height cap: 80px max desktop, default 64-72px.** No huge "agency" nav bars that eat 15% of the viewport. +* **Bento grids MUST have rhythm, not one-sided repetition.** Do not stack 6 left-image / right-text rows. Vary the composition: alternate full-width feature rows, asymmetric tile sizes, vertical breaks. +* **BENTO CELL COUNT RULE (mandatory):** A bento grid has EXACTLY as many cells as you have content for. 3 items → 3 cells (1+2 split, or 2+1, or asymmetric trio). 5 items → 5 cells (2+3, 3+2, hero+4, etc.). If your grid has an empty cell in the middle or at the end, you planned wrong. Re-shape the grid; do not paste a blank tile. +* **Section-Layout-Repetition Ban.** Once you use a layout family for a section (e.g., 3-column-image-cards, full-width-quote, split-text-image), that family can appear at most ONCE on the page. "Selected commissions" must not look like "What we do." A landing page with 8 sections must use at least 4 different layout families. +* **ZIGZAG ALTERNATION CAP (mandatory).** Alternating "left-image + right-text" then "left-text + right-image" zigzag layout = banal. Max 2 sections in a row with this image+text-split pattern. The 3rd consecutive image+text split is a Pre-Flight Fail. Break the pattern with a full-width section, a vertical-stack section, a bento grid, a marquee, or a different layout family. +* **EYEBROW RESTRAINT (mandatory, the #1 violated rule in production tests).** An "eyebrow" is the small uppercase wide-tracking label sitting above a section headline (e.g. `FOUR COLORWAYS`, `SELECTED WORK`, `THE HARDWARE`, `Git-native task management`). Typical CSS signature: `text-[11px] uppercase tracking-[0.18em]`, `font-mono text-[10.5px] uppercase tracking-[0.22em]`. Every AI-built site puts an eyebrow above EVERY section header, producing the same templated rhythm. Hard rule: + - **Maximum 1 eyebrow per 3 sections.** Hero counts as 1. So a page with 9 sections may use at most 3 eyebrows total. + - If section A has an eyebrow, the next 2 sections cannot have one. + - **Pre-Flight Check is mechanical:** count instances of `uppercase tracking` (or similar small-caps mono labels above headlines) across all section components. If count > ceil(sectionCount / 3), the output fails. + - **What to do instead of an eyebrow:** drop it entirely. The headline alone is enough. If you need to categorize a section, the section's location on the page already categorizes it; no label needed. +* **SPLIT-HEADER BAN (mandatory).** The pattern "left big headline + right small explainer paragraph" as a section header (left col-span-7/8, right col-span-4/5 with a small body paragraph floating in the right column) is **banned as default**. Sections should have ONE focused message. If you genuinely need both a headline and an explainer paragraph, stack them vertically (headline on top, body below, max-width 65ch). Reach for the split-header pattern only when there is a real compositional reason (e.g., the right column carries a visual or interactive element, not just filler text). +* **Bento Background Diversity (mandatory).** Bento and feature-grid sections cannot be 6 white-on-white cards with text inside. At least 2-3 cells in any multi-cell grid need real visual variation: a real image, a brand-appropriate gradient (not AI-purple), a pattern, a tinted background. A cream-on-cream bento with only typography inside reads as boring AI default, even when the rest of the page is good. +* **Mobile collapse must be explicit per section.** For every multi-column layout, declare the `< 768px` fallback in the same component. No "it'll work, Tailwind handles it" assumptions. + +### 4.8 Image & Visual Asset Strategy + +Landing pages and portfolios are **visual products**. Text-only pages with fake-screenshot divs are slop. + +**Priority order for visual assets:** +1. **Image-generation tool first.** If ANY image-gen tool is available in the environment (`generate_image`, MCP image tool, IDE-integrated gen, OpenAI image tools, etc.) you MUST use it to create section-specific assets: hero photography, product shots, texture backgrounds, mood images. Generate at the right aspect ratio for the section. Do not skip this step because hand-rolled CSS feels faster. +2. **Real web images second.** When no gen tool is available, use real photography sources. Acceptable defaults: + * `https://picsum.photos/seed/{descriptive-seed}/{w}/{h}` for placeholder photography (seed should describe the section, e.g. `marrow-cookware-kitchen`) + * Actual stock or brand URLs when the brief provides them + * Open-license sources (Unsplash via direct URL, Pexels) if explicitly allowed +3. **Last resort: tell the user.** If neither is possible, do NOT fill the page with hand-rolled SVG illustrations or div-based "fake screenshots." Instead, leave clearly-labeled placeholder slots (``) and at the end of the response say: *"This page needs real images at: \[list of placements\]. Please generate or provide them."* + +**Even minimalist sites need real images.** A pure-text page is not minimalism. It is incomplete work. Even an editorial Linear-style site needs at least 2-3 real images (hero, one product/lifestyle shot, one supporting image). Generate B&W minimalist photography if the brief is restrained; do not skip images entirely because the dial is low. + +**Real company logos for social proof.** When the brief calls for a "Trusted by / Used by / Customers" logo wall, do NOT default to plain text wordmarks (`Acme Co` styled in a row). Use real SVG logos: +* **Source: Simple Icons** (`https://cdn.simpleicons.org/{slug}/ffffff` for any color, or `simple-icons` npm package). Covers most known brands. +* **Alternative: devicon** for tech-stack logos (`@svgr/cli` or CDN). +* **Make-up the brand name? Then make-up an SVG mark too.** Generate a simple monogram (one letter in a circle, two-letter ligature, abstract glyph) rendered as an inline `` matching the page style. Plain text wordmarks for invented brand names look generic. +* **Always** ensure logos render in both light and dark mode (white-on-dark, black-on-light, or single-color theme variable). +* **LOGO-ONLY rule (mandatory):** logo wall = logos and nothing else. Do NOT print industry / category labels below each logo (no `Vercel` + `hosting` underneath, no `Stripe` + `payments`, no `Cloudflare` + `infra`). The logo is the credibility, the label adds nothing the user does not already know. Optional: brand name as alt-text for screen readers, optional link to the brand's site. That is it. + +**Hand-rolled illustrations:** +* SVG icons from libraries: fine (see Section 3.C). +* Hand-rolled decorative SVGs (custom illustrations, logos, marks): **strongly discouraged**, never as default. Acceptable only when: + - The brief explicitly calls for it ("draw me an SVG logo") + - It's a single, simple geometric mark (a square, a circle, a wordmark in display type) + - You're confident in the output quality + +**Div-based fake screenshots are banned.** A "hand-built product preview" rendered with `
` rectangles, fake task lists, fake dashboards, fake terminal windows is a Tell. If you need to show a product: +* Use a real screenshot URL if one exists +* Generate one via image tool +* Use a real component preview (an actual mini-version of the UI inside the page) +* Or skip the preview entirely and use editorial photography + +**Hero needs a real visual.** Text + gradient blob is not a hero - it's a placeholder. + +### 4.9 Content Density + +Landing pages live on the **first impression**, not the full read. Cut ruthlessly. + +* **Default content shape per section:** short headline (≤ 8 words) + short sub-paragraph (≤ 25 words) + one visual asset OR one CTA. Anything more must be justified by the section's job. +* **No data-dump sections.** A 20-row publication table, a 30-row award list, a giant pricing matrix on a marketing page = wrong layout. Use: + - Top 3-5 highlights + "View full list" link + - Marquee / carousel for breadth + - Different page entirely if the data is the product +* **Long lists need a different UI component, not a longer list.** Default `
    ` with bullets / `divide-y` rows is the lazy choice. If you have > 5 items, reach for one of these instead: + - 2-column split with grouped items + - Card grid with image + label per item + - Tabs / accordion if items are categorisable + - Horizontal scroll-snap pills + - Carousel for breadth-heavy lists (testimonials, logos, capabilities) + - Marquee for "lots-of-things-that-don't-need-individual-attention" + A spec sheet with 10 rows + a hairline under every row is the WORST default. Either group rows into 2-3 chunks with sparse dividers, or move to a card-per-spec layout. +* **Spec sheets specifically (the Marrow-cookware pattern).** A long product specification table with `border-b` on every row is the AI default for cookware / hardware / apparel / artisan-goods briefs. Banned. Concrete alternatives: + - **2-col card grid:** each spec gets its own card with the spec name, the value (large display number), and a one-line "why it matters" body. Cards arranged 2-col on desktop, 1-col mobile. + - **Scroll-snap horizontal pills:** each spec is a pill, user can flick through. + - **Grouped chunks:** group 10 specs into 3 logical clusters (e.g. "Materials", "Cooking", "Warranty"), each cluster gets ONE soft divider and a cluster heading. + - **Featured-vs-rest:** 3-4 hero specs visualised as large display tiles, the rest collapsed under a "View full specifications" disclosure. + +* **COPY SELF-AUDIT (mandatory before ship):** Before declaring any task done, re-read every visible string on the page (headlines, subheads, eyebrows, button labels, body copy, captions, alt text, footer text, error messages). Flag any string that is: + - **Grammatically broken** ("free on its past", "two plans but one is honest", "to put it on the table" out of context) + - **Has unclear referents** ("we plan to stay that way" without prior context) + - **Sounds like AI hallucination** (cute-but-wrong wordplay, forced metaphors that don't track, "elegant nothing" phrases) + - **Reads like an LLM trying to sound thoughtful** (passive-aggressive humility, fake-craftsman labels, mock-poetic micro-meta) + Rewrite every flagged string. If unsure whether a string makes sense, replace it with a plain functional sentence. AI-generated cute copy is worse than boring copy. +* **Fake-precise numbers are flagged.** Numbers like `92%`, `4.1×`, `48k`, `5.8 mm`, `13.4 lb` either: + - Come from real data (brief, brand guidelines, public metrics) - fine + - Are explicitly labeled as mock (``, "example", "sample data") - fine + - Are AI-invented spec aesthetics - banned. Don't fake engineering precision the brand doesn't claim. +* **One copy register per page.** Don't mix technical mono ("47 tasks · 0.6 ctx-switches/day"), editorial prose, and marketing punch in the same composition unless the brand voice explicitly calls for it. + +### 4.10 Quotes & Testimonials + +* **Max 3 lines** of quote body. Never 6. If the original quote is longer → cut it. A landing-page quote is a snippet, not the full review. +* For very small font sizes (e.g. footer-style testimonials), the line cap can stretch slightly. Spirit: "fits in a glance." +* **No em-dashes inside the quote text** as design flourish (long pauses, kinetic em-dashes, em-dash-bullets). See Section 9.G - em-dash is completely banned. +* Attribution: name + role + (optionally) company. Never name only ("- Sarah"). +* Quote marks: use real typographic quotes ( " " ) or none at all. Not straight ASCII ( " ). + +### 4.11 Page Theme Lock (Light / Dark Mode Consistency) + +The page has ONE theme. Sections do not invert. + +* If the page is dark mode, ALL sections are dark mode. No light-mode-warm-paper section sandwiched between dark sections (or vice versa). The user must not feel they walked into a different website mid-scroll. +* The exception: if the brief explicitly calls for a "Color Block Story" or "Theme Switch on Scroll" device AND that is a deliberate composition (one full theme switch with a strong transition, not random alternation), it is allowed once per page. +* Default behaviour: pick light, dark, or auto (`prefers-color-scheme`) at the page level and lock it. Section-level background tints within the same theme family are fine (`bg-zinc-950` next to `bg-zinc-900`); flipping to `bg-amber-50` in the middle of a `bg-zinc-950` page is broken. +* When using a design system with built-in theming (Radix Themes, shadcn/ui with ``), set the theme ONCE in `layout.tsx` or the page root. Do not let individual sections override. + +--- + +## 5. CONTEXT-AWARE PROACTIVITY + +These are tools, not defaults. Use them when the design read calls for them. **None of these fire automatically.** + +* **Liquid Glass / Glassmorphism:** Appropriate for premium consumer, Apple-adjacent, luxury brand, or media-overlay vibes. Inappropriate for dashboards, public-sector, or "boring B2B." When used, go beyond `backdrop-blur`: add a 1px inner border (`border-white/10`) and a subtle inner shadow (`shadow-[inset_0_1px_0_rgba(255,255,255,0.1)]`) for physical edge refraction. Provide a solid-fill fallback under `prefers-reduced-transparency`. +* **Magnetic Micro-physics:** Use when `MOTION_INTENSITY > 5` AND the brief reads premium / playful / agency. Implement EXCLUSIVELY with Motion's `useMotionValue` / `useTransform` outside the React render cycle. Never `useState`. See Section 3.B. +* **Perpetual Micro-Interactions** (Pulse, Typewriter, Float, Shimmer, Carousel): Use when `MOTION_INTENSITY > 5` AND the section actively benefits from motion (status indicators, live feeds, AI-feel). **Not every card needs an infinite loop.** If a section is informational, leave it still. Apply Spring Physics (`type: "spring", stiffness: 100, damping: 20`) - no linear easing. +* **"Motion claimed, motion shown."** If `MOTION_INTENSITY > 4`, the page must actually move: entry transitions on hero, scroll-reveal on key sections, hover physics on CTAs, at minimum. A static page that claims `MOTION_INTENSITY: 7` is broken. Conversely, if you cannot ship working motion in the available scope, drop the dial to 3 and ship a clean static page. Never half-build motion that breaks (cut-off ScrollTriggers, jumpy enters, missing cleanups). +* **MOTION MUST BE MOTIVATED (mandatory).** Before adding any animation, ask: "what does this animation communicate?" Valid answers: hierarchy (drawing attention to the right thing), storytelling (revealing content in sequence that matches a narrative), feedback (acknowledging a user action), state transition (showing something changed). Invalid answer: "it looked cool". GSAP everywhere because GSAP is available is amateur. Each ScrollTrigger, each marquee, each pinned section needs a reason. If you cannot articulate the reason in one sentence, drop the animation. +* **MARQUEE MAX-ONE-PER-PAGE (mandatory).** Horizontal scrolling text marquees ("logos endlessly scrolling", "manifesto scrolling sideways", "kinetic word strip") are appropriate at most ONCE per page. Two or more marquees on the same page reads as lazy filler. Pick the one section where the marquee actually serves the content; the others get a different layout. +* **GSAP Sticky-Stack Pattern (when scroll-stack is used).** A "card stack on scroll" must be a REAL sticky-stack, not a sequential reveal list. See Section 5.A below for the canonical code skeleton. Common failure: trigger fires halfway through scroll instead of pinning at viewport top. Fix: `start: "top top"` not `start: "top center"` or `"top 80%"`. +* **GSAP Horizontal-Pan Pattern (when horizontal scroll-hijack is used).** See Section 5.B below for the canonical skeleton. Common failure: animation starts before the section is pinned, so the user sees half a slide. Same fix: `start: "top top"`, pin the wrapper, scrub the inner track. + +### 5.A Sticky-Stack - Canonical Skeleton + +```tsx +"use client"; +import { useRef, useEffect } from "react"; +import { gsap } from "gsap"; +import { ScrollTrigger } from "gsap/ScrollTrigger"; +import { useReducedMotion } from "motion/react"; + +gsap.registerPlugin(ScrollTrigger); + +export function StickyStack({ cards }: { cards: React.ReactNode[] }) { + const ref = useRef(null); + const reduce = useReducedMotion(); + + useEffect(() => { + if (reduce || !ref.current) return; + const ctx = gsap.context(() => { + const cardEls = gsap.utils.toArray(".stack-card"); + cardEls.forEach((card, i) => { + if (i === cardEls.length - 1) return; + ScrollTrigger.create({ + trigger: card, + start: "top top", // pin at viewport top + endTrigger: cardEls[cardEls.length - 1], + end: "top top", + pin: true, + pinSpacing: false, + }); + gsap.to(card, { + scale: 0.92, + opacity: 0.55, + ease: "none", + scrollTrigger: { + trigger: cardEls[i + 1], + start: "top bottom", + end: "top top", + scrub: true, + }, + }); + }); + }, ref); + return () => ctx.revert(); + }, [reduce]); + + return ( +
    + {cards.map((card, i) => ( +
    + {card} +
    + ))} +
    + ); +} +``` + +Critical points: `start: "top top"`, `pin: true`, every card except the last is pinned, the scale/opacity transform is driven by the NEXT card's scroll trigger (so previous card shrinks as next one arrives). + +### 5.B Horizontal-Pan - Canonical Skeleton + +```tsx +"use client"; +import { useRef, useEffect } from "react"; +import { gsap } from "gsap"; +import { ScrollTrigger } from "gsap/ScrollTrigger"; +import { useReducedMotion } from "motion/react"; + +gsap.registerPlugin(ScrollTrigger); + +export function HorizontalPan({ children }: { children: React.ReactNode }) { + const wrap = useRef(null); + const track = useRef(null); + const reduce = useReducedMotion(); + + useEffect(() => { + if (reduce || !wrap.current || !track.current) return; + const ctx = gsap.context(() => { + const distance = track.current!.scrollWidth - window.innerWidth; + gsap.to(track.current, { + x: -distance, + ease: "none", + scrollTrigger: { + trigger: wrap.current, + start: "top top", // pin starts when section top hits viewport top + end: () => `+=${distance}`, // scroll distance = track width minus viewport + pin: true, + scrub: 1, + invalidateOnRefresh: true, + }, + }); + }, wrap); + return () => ctx.revert(); + }, [reduce]); + + return ( +
    +
    + {children} +
    +
    + ); +} +``` + +Critical points: `start: "top top"`, `pin: true`, `end: "+=${distance}"` (scroll length = horizontal travel needed), `scrub: 1`. The wrapper is pinned, the inner track slides horizontally as the user scrolls vertically. + +### 5.C Scroll-Reveal Stagger - Canonical Skeleton (lighter alternative) + +For simple "items appear as they enter viewport" (no pinning), prefer Motion's `whileInView` over GSAP - lighter, no ScrollTrigger needed: + +```tsx +"use client"; +import { motion, useReducedMotion } from "motion/react"; + +export function RevealStagger({ items }: { items: string[] }) { + const reduce = useReducedMotion(); + return ( +
      + {items.map((item, i) => ( + + {item} + + ))} +
    + ); +} +``` + +Use this for: feature lists, testimonial grids, logo walls, anything that just needs "enter on scroll." Save GSAP for actual pin/scrub work. + +### 5.D Forbidden Animation Patterns + +* **`window.addEventListener("scroll", ...)`** is banned. It runs on every scroll frame, jank-prone, no batching. Use Motion's `useScroll()`, GSAP's `ScrollTrigger`, IntersectionObserver, or CSS `scroll-driven animations` (`animation-timeline: view()`). +* **Custom scroll progress calculations using `window.scrollY`** in React state. Same reason. Re-renders on every frame. +* **`requestAnimationFrame` loops that touch React state.** Use motion values (`useMotionValue` + `useTransform`) instead. +* **Layout Transitions:** Use Motion's `layout` and `layoutId` props for visible state changes (re-ordering lists, expanding modals, shared elements between routes). Do not wrap static content in `layout` props "for safety" - it costs measurement work. +* **Staggered Orchestration:** Use `staggerChildren` (Motion) or CSS cascade (`animation-delay: calc(var(--index) * 100ms)`) for reveal moments where sequence matters. For `staggerChildren`, parent (`variants`) and children MUST share the same Client Component tree. + +--- + +## 6. PERFORMANCE & ACCESSIBILITY GUARDRAILS + +### 6.A Hardware Acceleration +* Animate ONLY `transform` and `opacity`. Never animate `top`, `left`, `width`, `height`. +* Use `will-change: transform` sparingly - only on elements that will actually animate. + +### 6.B Reduced Motion (mandatory) +* **Any motion above `MOTION_INTENSITY > 3` MUST honor `prefers-reduced-motion`.** This is non-negotiable. +* In Motion: wrap with `useReducedMotion()` and degrade to static. +* In CSS: gate animations behind `@media (prefers-reduced-motion: no-preference)` or provide an override block under `@media (prefers-reduced-motion: reduce)` that disables. +* Infinite loops, parallax, scroll-hijack, and magnetic physics MUST collapse to static / instant under reduced motion. + +### 6.C Dark Mode (mandatory for any consumer-facing page) +* Design for **both modes from the start**. Never ship light-only or dark-only without explicit user instruction. +* Use Tailwind `dark:` variant OR CSS variables for tokens. Pick one strategy per project. +* **Do not prescribe specific dark-mode colors here.** The brief decides. Maintain visual hierarchy, brand identity, and WCAG AA contrast (AAA for body) across both modes. +* Respect `prefers-color-scheme: dark`. Default to system preference unless the brand insists on one mode. + +### 6.D Core Web Vitals Targets +* **LCP** < 2.5s. Hero image must be `next/image priority` or preloaded. +* **INP** < 200ms. Heavy work off main thread. +* **CLS** < 0.1. Reserve space for images, fonts, embeds. +* Run Lighthouse before declaring a page done. + +### 6.E DOM Cost +* Apply grain / noise filters EXCLUSIVELY to fixed, `pointer-events-none` pseudo-elements (e.g., `fixed inset-0 z-[60] pointer-events-none`). NEVER on scrolling containers - continuous GPU repaints destroy mobile FPS. +* Be aware of bundle size. Motion is not tiny. Three.js is large. Lazy-load anything that's not above-the-fold. + +### 6.F Z-Index Restraint +NEVER spam arbitrary `z-50` or `z-10`. Use z-index strictly for systemic layer contexts (sticky navbars, modals, overlays, grain). Document the z-index scale in a project constants file. + +--- + +## 7. DIAL DEFINITIONS (Technical Reference) + +### DESIGN_VARIANCE (Level 1-10) +* **1-3 (Predictable):** Symmetrical CSS Grid (12-col, equal fr-units), equal paddings, centered alignment. +* **4-7 (Offset):** `margin-top: -2rem` overlaps, varied image aspect ratios (4:3 next to 16:9), left-aligned headers over center-aligned data. +* **8-10 (Asymmetric):** Masonry layouts, CSS Grid with fractional units (`grid-template-columns: 2fr 1fr 1fr`), massive empty zones (`padding-left: 20vw`). +* **MOBILE OVERRIDE:** For levels 4-10, asymmetric layouts above `md:` MUST collapse to strict single-column (`w-full`, `px-4`, `py-8`) on viewports `< 768px`. + +### MOTION_INTENSITY (Level 1-10) +* **1-3 (Static):** No automatic animations. CSS `:hover` and `:active` states only. `prefers-reduced-motion` is the default mode anyway. +* **4-7 (Fluid CSS):** `transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1)`. `animation-delay` cascades for load-ins. Focus on `transform` and `opacity`. +* **8-10 (Advanced Choreography):** Complex scroll-triggered reveals, parallax, scroll-driven animation (CSS `animation-timeline` or GSAP ScrollTrigger). Use Motion hooks. **NEVER use `window.addEventListener('scroll')`** - it is a hard ban, not a "prefer-not." See Section 5.D for the allowed alternatives. + +### VISUAL_DENSITY (Level 1-10) +* **1-3 (Art Gallery):** Lots of white space. Huge section gaps (`py-32` to `py-48`). Expensive, clean. +* **4-7 (Daily App):** Standard web app spacing (`py-16` to `py-24`). +* **8-10 (Cockpit):** Tight paddings. No card boxes; 1px lines separate data. Mandatory: `font-mono` for all numbers. + +--- + +## 8. DARK MODE PROTOCOL + +Dual-mode by default. Never assume light-only unless the brief is print-emulating editorial. + +### 8.A Token Strategy (pick one, stick to it) +* **Tailwind `dark:` variant** (default for utility-first projects): every color utility paired with its dark variant (`bg-white dark:bg-zinc-950`, `text-gray-900 dark:text-gray-100`). +* **CSS variables** (for shadcn/ui, Radix Themes, or component libraries with theming): define semantic tokens (`--surface`, `--surface-elevated`, `--text-primary`, `--accent`) and swap values under `[data-theme="dark"]` or `@media (prefers-color-scheme: dark)`. + +### 8.B Do Not Prescribe Specific Colors Here +The brief and brand decide. This skill enforces only: +* **Contrast** - WCAG AA minimum for body text, AAA target for hero copy. +* **Hierarchy parity** - visual hierarchy that works in light must work in dark. If a CTA pops in light, it pops in dark. +* **Brand fidelity** - primary brand color stays recognisable. Don't desaturate the brand into a dark mode. +* **No pure `#000000` and no pure `#ffffff`** - use off-black (zinc-950, near-black warm gray) and off-white. Pure values kill depth. + +### 8.C Default Mode +Respect `prefers-color-scheme` unless the brand insists. Add a manual toggle if either mode would lose key brand expression. + +### 8.D Test in Both Modes Before Finishing +Open the page in both modes during development. Do not ship a page you've only seen in one mode. + +--- + +## 9. AI TELLS (Forbidden Patterns) + +Avoid these signatures unless the brief explicitly asks for them. + +### 9.A Visual & CSS +* **NO neon / outer glows** by default. Use inner borders or subtle tinted shadows. +* **NO pure black (`#000000`).** Off-black, zinc-950, or charcoal. +* **NO oversaturated accents.** Desaturate to blend with neutrals. +* **NO excessive gradient text** for large headers. +* **NO custom mouse cursors.** Outdated, accessibility-hostile, perf-hostile. + +### 9.B Typography +* **AVOID Inter as default.** See Section 4.1. Override path exists. +* **NO oversized H1s** that just scream. Control hierarchy with weight + color, not raw scale. +* **Serif constraints:** Serif for editorial / luxury / publication. Not for dashboards. + +### 9.C Layout & Spacing +* **Mathematically perfect** padding and margins. No floating elements with awkward gaps. +* **NO 3-column equal feature cards.** The generic "three identical cards horizontally" feature row is banned. Use 2-column zig-zag, asymmetric grid, scroll-pinned, or horizontal-scroll alternative. + +### 9.D Content & Data ("Jane Doe" Effect) +* **NO generic names.** "John Doe", "Sarah Chan", "Jack Su" → use creative, realistic, locale-appropriate names. +* **NO generic avatars.** No SVG "egg" or Lucide user icons → use believable photo placeholders or specific styling. +* **NO fake-perfect numbers.** Avoid `99.99%`, `50%`, `1234567`. Use organic, messy data (`47.2%`, `+1 (312) 847-1928`). +* **NO startup-slop brand names.** "Acme", "Nexus", "SmartFlow", "Cloudly" → invent contextual, premium names that sound real. +* **NO filler verbs.** "Elevate", "Seamless", "Unleash", "Next-Gen", "Revolutionize" → concrete verbs only. + +### 9.E External Resources & Components +* **NO hand-rolled SVG icons.** Use Phosphor / HugeIcons / Radix / Tabler. Lucide on explicit request only. +* **Hand-rolled decorative SVGs strongly discouraged** as default (see Section 4.8). +* **NO div-based fake screenshots.** Never build a fake product UI out of `
    ` rectangles to simulate a screenshot. Use real images, generated images, or skip the preview. +* **NO broken Unsplash links.** Use `https://picsum.photos/seed/{descriptive-string}/{w}/{h}`, or generated photo placeholders, or actual assets. +* **shadcn/ui customization:** Allowed, but NEVER in default state. Customize radii, colors, shadows, typography to the project aesthetic. +* **Production-Ready Cleanliness:** Code visually clean, memorable, meticulously refined. + +### 9.F Production-Test Tells (banned outright) + +These patterns came out of real LLM-generated landing-page tests. They are the signatures the model defaults to when it tries to "look designed." Treat them as hard bans unless the brief explicitly calls for one. + +**Hero & top-of-page** +* **NO version labels in the hero.** `V0.6`, `v2.0`, `BETA`, `INVITE-ONLY PREVIEW`, `EARLY ACCESS`, `ALPHA` - banned as default eyebrows. Only acceptable when the brief is explicitly about a product launch / preview status. +* **NO "Brand · No. 01"-style sub-eyebrows.** "Marrow · No. 01 · The 6-quart" type micro-meta lines. Skip them. + +**Section numbering & micro-labels** +* **NO section-number eyebrows.** `00 / INDEX`, `001 · Capabilities`, `002 · Featured commission`, `06 · how it works`, `05 · The honest table` - banned. Eyebrows should name the topic in plain language, not enumerate. +* **NO `01 / 4`-style pagination on images or bento tiles.** If the user can count, they don't need the label. +* **NO `Scroll · 001 Capabilities`-style scroll cues.** A simple arrow or "Scroll" is enough; no section-number prefix. +* **NO "Index of Work, 2018 - 2026"-style range labels** as eyebrows. Just say what the section is. + +**Separators & dots** +* **The middle-dot (`·`) is rationed.** Maximum 1 per line in metadata strips. Do NOT use it as the default separator for everything ("foo · bar · baz · qux · quux"). If you need a separator family, prefer line breaks, hairlines, or columns. +* **NO decorative colored status dots on every list/nav/badge.** A colored dot before "ONE Q4 SLOT OPEN" or before every nav link, or every task row - banned by default. Acceptable only when the dot conveys actual semantic state (a server status, an availability flag) and is used sparingly. + +**Em-dashes & typography flourishes** +* **NO em-dash (`—`) as a design element OR anywhere else.** See Section 9.G below for the complete, non-negotiable ban. The em-dash character is forbidden in headlines, eyebrows, pills, body copy, quotes, attribution, captions, button text, and alt text. Use the regular hyphen (`-`). +* **NO `
    `-broken-and-italicized headlines** as a default "design move." "for thirty\*years.*" type splits. Headlines should read naturally first, get clever only when the brief demands it. +* **NO vertical rotated text** ("INDEX OF WORK, 2018 - 2026" rotated 90°). Agency-portfolio cliché. Use it only when the brief is explicitly agency / Awwwards / experimental AND it serves a real composition purpose. +* **NO crosshair / hairline grid lines as decoration.** Vertical and horizontal lines drawn just to make the page "feel designed" - banned. Use them only when they organize real content. + +**Fake product previews** +* **NO div-based fake product UI in the hero** (fake task list, fake terminal, fake dashboard built from styled divs). It is the #1 LLM-design Tell. Use a real screenshot, a generated image, a real component preview, or none at all. +* **NO fake version footers** ("v0.6.2-rc.1", "last sync 4s ago · main") inside fake screenshots. Adds nothing, screams AI. + +**Marketing-copy Tells** +* **NO "Quietly in use at" / "Quietly trusted by"** social-proof headers. Use natural language: "Trusted by", "Used at", "Customers include", or skip the heading entirely if the logos speak. +* **NO "From the field" / "Field notes" / "Currently on the bench" / "On our desks" / "Loose plates" style poetic labels** on quote, blog, or sidebar sections. Reads as performative-craftsman. Use plain functional labels ("Testimonials", "Latest writing", "Now working on") or skip the label. +* **NO "We respect the French ones"-style** mock-humble industry-references in body copy. Cute and AI-y. +* **NO weather / locale strips** ("LIS 14:23 · 18°C") in headers/footers unless the brief is explicitly about a place / time-zone-distributed studio. +* **NO micro-meta-sentences under eyebrows.** Sentences like *"Each of these is a feature we ship today, not a roadmap promise. The list will stay short on purpose."* sitting under a section heading are clutter. Eyebrow + Headline + Body is enough. +* **NO generic step labels.** "Stage 1 / Stage 2 / Stage 3", "Step 1 / Step 2 / Step 3", "Phase 01 / Phase 02 / Phase 03", "Pass One / Pass Two / Pass Three". Banned. The actual step content is the label. If you must show progression, use the verb-noun directly ("Install", "Configure", "Ship") not "Stage 1: Install". + +**Pills, labels and version stamps** +* **NO pills/labels/tags overlaid on images.** No `` overlays on photos with tags like `Brand · 02`, `PLATE · BRAND`, `Field notes - journal`. Either let the image speak alone, or add a caption directly below (outside the image). +* **NO photo-credit captions as decoration.** Strings like `Field study no. 12 · Ines Caetano`, `Plate 03 · House archive`, `Frame XII · 35mm` under stock/picsum images are pretentious. Photo credit is allowed ONLY when there is a real photographer being credited for a real photo (with permission). Otherwise: skip the caption or use a one-line functional caption ("The 6-quart, in Sage."). +* **NO version footers on marketing pages.** Footer strings like `v1.4.2`, `Build 0048`, `last sync 4s ago · main` are CLI / devtool fixtures, not landing-page content. Banned on marketing/landing/portfolio pages. +* **NO "Reservation 412 of 800"-style live-stock counters** as decoration. Only if the brief is explicitly a limited-run waitlist with real data. + +**Decoration text strips** +* **NO decoration text strip at hero bottom.** Patterns like `BRAND. MOTION. SPATIAL.`, `TYPE / FORM / MOTION`, `DESIGN · BUILD · SHIP`, `ESTD. 2018 · LISBON · BRAND. MOTION. SPATIAL.` as a small mono-caps strip across the bottom of the hero are an agency-portfolio cliché. Banned by default. Only acceptable when the strip carries real, navigable links (sticky bottom nav) or real status info (cookie banner, build info on a docs site). +* **NO floating top-right sub-text in section headings.** Pattern: section has a giant left-aligned headline; in the top-right corner of the same section header there is a small explainer paragraph floating with no clear alignment to anything else. That floater is the Tell. Either put the sub-text directly under the headline, or build a clean 2-column header (left: headline, right: aligned body), but not a tiny corner paragraph. + +**Lists, dividers and scoring** +* **NO `border-t` + `border-b` on every row of a long list / spec table.** Pick one (bottom-border between rows OR top-border above the group) and use it sparsely. A 10-row spec table with hairlines under each row is the laziest layout - see Section 4.9 for alternative UI components. +* **NO scoring/progress bars with filled background tracks** as comparison visuals. If you need to show "X out of Y" comparisons, prefer a number + small icon, or a tiny inline bar WITHOUT a background track. Big filled `bg-zinc-200` tracks with a partial fill on top are dashboard-UI clutter on a landing page. + +**Locale, time, scroll cues** +* **Locale / city-name / time / weather strips are banned for 99% of briefs.** "Lisbon, working with founders" in the hero, "1200-690 Lisbon, Portugal" in the footer, "Lisbon 14:23 · 18°C" in the nav. These are agency-portfolio decoration tells. Allowed ONLY when: the brief explicitly describes a globally-distributed studio with timezone-relevant work, OR a travel-focused brand, OR a real-world physical venue. A single contact-address mention in the footer is fine; an atmospheric locale strip is not. +* **Scroll cues are banned.** `Scroll`, `↓ scroll`, `Scroll to explore`, `Scroll to walk through it`, animated mouse-wheel icons. If the user has not scrolled yet, they are looking at the hero. They know what scroll is. The bottom of the viewport does not need a label. +* **ZERO decorative status dots by default.** A coloured dot before nav items, before list rows, before badges, before status labels is a Tell. Only acceptable when conveying real semantic state (a live indicator on actual server status, a live availability flag) and limited to one per page section. + +### 9.G EM-DASH BAN (the single most-violated Tell) + +**Em-dash (`—`) is COMPLETELY banned.** It is the LLM's signature stylistic crutch and it is the #1 visual Tell in production tests. There is no "limited use" allowance, no "natural language frequency" allowance, no "in body copy is fine" allowance. None. + +* **Banned in headlines.** Use a period or a comma. +* **Banned in eyebrows / labels / pills / button text / image captions / nav items.** Replace with line breaks, columns, or hairlines. +* **Banned in body copy.** Restructure the sentence: two sentences with a period, OR a comma, OR parentheses, OR a colon. +* **Banned in quote attribution.** Use a normal hyphen with spaces (` - `) or a line break + smaller-weight name. +* **Banned in en-dash form too (`–`) when used as a separator.** Date ranges (`2018-2026`) use a hyphen. Number ranges (`€40-80k`) use a hyphen. + +The ONLY permitted dash characters on the page are: +* Regular hyphen `-` (for compound words, ranges, line dividers in markup) +* Minus sign in math (`-5°C`) + +If your output contains a single `—` or `–` anywhere visible to the user, the output fails the Pre-Flight Check and must be rewritten. + +This rule is non-negotiable. The agent has historically ignored em-dash limits when phrased as "use sparingly." The phrasing here is binary: zero em-dashes. + +--- + +## 10. REFERENCE VOCABULARY (Pattern Names the Agent Should Know) + +This is a vocabulary, not a library. The agent should KNOW these pattern names to communicate about them, design with them in mind, and reach for them when the design read calls for them. **Implementations and code sketches live in the Block Library (Section 12), which is populated iteratively.** + +### Hero Paradigms +* **Asymmetric Split Hero** - Text on one side, asset on the other, generous white space. +* **Editorial Manifesto Hero** - Large type, no asset, almost-poster. +* **Video / Media Mask Hero** - Type cut out as mask over video background. +* **Kinetic-Type Hero** - Animated typography as the primary visual. +* **Curtain-Reveal Hero** - Hero parts on scroll like a curtain. +* **Scroll-Pinned Hero** - Hero stays pinned while content scrolls behind. + +### Navigation & Menus +* **Mac OS Dock Magnification** - Edge nav, icons scale fluidly on hover. +* **Magnetic Button** - Pulls toward cursor. +* **Gooey Menu** - Sub-items detach like viscous liquid. +* **Dynamic Island** - Morphing pill for status / alerts. +* **Contextual Radial Menu** - Circular menu expanding at click point. +* **Floating Speed Dial** - FAB springing into curved secondary actions. +* **Mega Menu Reveal** - Full-screen dropdown, stagger-fade content. + +### Layout & Grids +* **Bento Grid** - Asymmetric tile grouping (Apple Control Center). +* **Masonry Layout** - Staggered grid, no fixed row height. +* **Chroma Grid** - Borders / tiles with subtle animating gradients. +* **Split-Screen Scroll** - Two halves sliding in opposite directions. +* **Sticky-Stack Sections** - Sections that pin and stack on scroll. + +### Cards & Containers +* **Parallax Tilt Card** - 3D tilt tracking mouse coordinates. +* **Spotlight Border Card** - Borders illuminate under cursor. +* **Glassmorphism Panel** - Frosted glass with inner refraction. +* **Holographic Foil Card** - Iridescent rainbow shift on hover. +* **Tinder Swipe Stack** - Physical card stack, swipe-away. +* **Morphing Modal** - Button expands into its own dialog. + +### Scroll Animations +* **Sticky Scroll Stack** - Cards stick and physically stack. +* **Horizontal Scroll Hijack** - Vertical scroll → horizontal pan. +* **Locomotive / Sequence Scroll** - Video / 3D sequence tied to scrollbar. +* **Zoom Parallax** - Central background image zooming on scroll. +* **Scroll Progress Path** - SVG line drawing along scroll. +* **Liquid Swipe Transition** - Page transition like viscous liquid. + +### Galleries & Media +* **Dome Gallery** - 3D panoramic gallery. +* **Coverflow Carousel** - 3D carousel with angled edges. +* **Drag-to-Pan Grid** - Boundless draggable canvas. +* **Accordion Image Slider** - Narrow strips expanding on hover. +* **Hover Image Trail** - Mouse leaves popping image trail. +* **Glitch Effect Image** - RGB-channel shift on hover. + +### Typography & Text +* **Kinetic Marquee** - Endless text bands reversing on scroll. +* **Text Mask Reveal** - Massive type as transparent window to video. +* **Text Scramble Effect** - Matrix-style decoding on load / hover. +* **Circular Text Path** - Text curving along spinning circle. +* **Gradient Stroke Animation** - Outlined text with running gradient. +* **Kinetic Typography Grid** - Letters dodging the cursor. + +### Micro-Interactions & Effects +* **Particle Explosion Button** - CTA shatters into particles on success. +* **Liquid Pull-to-Refresh** - Reload indicator like detaching droplets. +* **Skeleton Shimmer** - Shifting light reflection across placeholders. +* **Directional Hover-Aware Button** - Fill enters from cursor's exact side. +* **Ripple Click Effect** - Wave from click coordinates. +* **Animated SVG Line Drawing** - Vectors drawing themselves in real time. +* **Mesh Gradient Background** - Organic lava-lamp blobs. +* **Lens Blur Depth** - Background UI blurred to focus foreground action. + +### Animation Library Choice +* **Motion (`motion/react`)** - default for UI / Bento / state-change motion. +* **GSAP + ScrollTrigger** - for full-page scrolltelling and scroll hijacks. Isolate in dedicated leaf components with `useEffect` cleanup. +* **Three.js / WebGL** - for canvas backgrounds and 3D scenes. Same isolation rule. +* **NEVER mix GSAP / Three.js with Motion in the same component tree.** They fight over the same frames. + +--- + +## 11. REDESIGN PROTOCOL + +This skill handles **greenfield builds AND redesigns**. Misclassifying the mode is the single biggest source of bad redesign output. + +### 11.A Detect the Mode (first action) +* **Greenfield** - no existing site, or full overhaul approved. Dial baseline from Section 1. +* **Redesign - Preserve** - modernise without breaking the brand. Audit first, extract brand tokens, evolve gradually. +* **Redesign - Overhaul** - new visual language on top of existing content. Treat as greenfield for visuals; preserve content and IA. + +If ambiguous, ask **once**: *"Should this redesign preserve the existing brand, or are we starting visually from scratch?"* + +### 11.B Audit Before Touching +Document the current state before proposing changes: +* **Brand tokens** - primary / accent colors, type stack, logo treatment, radii. +* **Information architecture** - page tree, primary nav, key conversion paths. +* **Content blocks** - what exists, what's doing work, what's filler. +* **Patterns to preserve** - signature interactions, recognisable hero, copy voice. +* **Patterns to retire** - AI-slop tells, broken layouts, dead links, generic stock imagery, perf traps. +* **Dial reading of the existing site** - infer current `DESIGN_VARIANCE` / `MOTION_INTENSITY` / `VISUAL_DENSITY`. That's your starting point, not the baseline. +* **SEO baseline** - current ranking pages, meta titles, structured data, OG cards. **SEO migration is the #1 redesign risk.** + +### 11.C Preservation Rules +* **Do not change information architecture** unless asked. Keep page slugs, anchor IDs, primary nav labels stable for SEO and muscle memory. +* **Extract brand colors before applying Section 4.2.** A brand that is already purple stays purple - apply the LILA RULE's override. +* **Preserve copy voice** unless asked for a rewrite. Visual modernisation ≠ content rewrite. +* **Honor existing accessibility wins.** Do not regress focus states, alt text, keyboard nav, contrast. +* **Respect existing analytics events.** Do not rename buttons, form fields, section IDs that downstream tracking depends on. + +### 11.D Modernisation Levers (priority order) +Apply in order - stop when the brief is satisfied: +1. **Typography refresh** - biggest visual lift per unit of risk. +2. **Spacing & rhythm** - increase section padding, fix vertical rhythm. +3. **Color recalibration** - desaturate, unify neutrals, keep brand accent. +4. **Motion layer** - add `MOTION_INTENSITY`-appropriate micro-interactions to existing components. +5. **Hero & key-section recomposition** - restructure top-of-funnel using Section 10 vocabulary. +6. **Full block replacement** - only when the existing block is unsalvageable. + +### 11.E Decision Tree: Targeted Evolution vs Full Redesign +* IA, content, and SEO sound → **targeted evolution** (Levers 1-4). ~70% of value at ~40% of risk. +* Visual debt is structural (broken IA, no design system, broken mobile) → **full redesign** with strict content preservation. +* Brand itself is changing → **greenfield**. + +### 11.F What Never Changes Silently +Never modify without explicit user approval: +* URL structure / route slugs. +* Primary nav labels. +* Form field names or order (breaks analytics + autofill). +* Brand logo or wordmark. +* Existing legal / consent / cookie copy. + +--- + +## 12. THE BLOCK LIBRARY (Contract - Implementations Land Here Iteratively) + +The Reference Vocabulary (Section 10) names patterns. The Block Library implements them with real props, real motion specs, and real code sketches. + +**Status:** schema defined here. Blocks will be added iteratively. Do not freelance new blocks without following this schema. + +### 12.A File Location +``` +skills/taste-skill/blocks/ + hero/ + asymmetric-split.md + editorial-manifesto.md + kinetic-type.md + ... + feature/ + bento-grid.md + sticky-scroll-stack.md + zig-zag.md + ... + social-proof/ + pricing/ + cta/ + footer/ + navigation/ + portfolio/ + transition/ +``` + +### 12.B Required Frontmatter +```yaml +--- +name: asymmetric-split-hero +category: hero +dial_compatibility: + variance: [6, 10] + motion: [3, 10] + density: [2, 5] +when_to_use: "Landing pages with one strong asset and one strong message. Default hero for SaaS, agency, premium consumer." +not_for: "Editorial / manifesto launches where the message IS the design." +stack: ["react", "next", "tailwind", "motion"] +--- +``` + +### 12.C Required Body Sections +1. **Visual sketch** - short ASCII or description of the layout. +2. **Props API** - the component's interface. +3. **Code sketch** - minimal working implementation (Server Component default, Client island for motion). +4. **Mobile fallback** - explicit collapse rules for `< 768px`. +5. **Motion variants** - one variant per `MOTION_INTENSITY` band (1-3, 4-7, 8-10). Reduced-motion fallback explicit. +6. **Dark-mode notes** - token strategy specific to this block. +7. **Anti-patterns** - common ways this block goes wrong. +8. **References** - links to real examples in production. + +### 12.D Block-Library Discipline +* One block per file. No multi-block files. +* Every block must work standalone (drop it into a page, it renders). +* Every block must pass the Pre-Flight Check (Section 14). +* Blocks that depend on a design system from Section 2.A live under `blocks//--.md` (e.g. `feature/bento-grid--material.md`). + +--- + +## 13. OUT OF SCOPE + +This skill is NOT for: +* Dashboards / dense product UI / admin panels (use Fluent, Carbon, Atlassian, or Polaris from Section 2.A). +* Data tables (use TanStack Table or AG Grid). +* Multi-step forms / wizards (use Form-specific patterns; this skill won't make them better). +* Code editors (use Monaco / CodeMirror with their official skinning). +* Native mobile (use Apple HIG / Material directly). +* Realtime collab UIs (presence, cursors, OT-aware - different problem class). + +If the brief is one of the above, **say so explicitly**, point to the right tool, and only apply this skill's marketing-page / about-page / landing-page parts to the surfaces where they apply. + +--- + +## 14. FINAL PRE-FLIGHT CHECK + +Run this matrix before outputting code. This is the last filter. + +**THIS IS NOT OPTIONAL. Run every box. If any box fails, the output is not done.** + +- [ ] **Brief inference** declared (Section 0.B one-liner)? +- [ ] **Dial values** explicit and reasoned from the brief, not silently using baseline? +- [ ] **Design system** chosen from Section 2 if applicable, or aesthetic labeled honestly? +- [ ] **Redesign mode** detected and audit performed (if applicable, Section 11)? +- [ ] **ZERO em-dashes (`—`) anywhere on the page.** Headlines, eyebrows, pills, body, quotes, attribution, captions, buttons, alt text. Zero. (Section 9.G - non-negotiable.) +- [ ] **Page Theme Lock**: ONE theme (light, dark, or auto) for the whole page. No section flips to inverted mode mid-page (Section 4.11)? +- [ ] **Color Consistency Lock**: one accent color used identically across all sections (Section 4.2)? +- [ ] **Shape Consistency Lock**: one corner-radius system applied consistently (Section 4.4)? +- [ ] **Button Contrast Check**: every CTA text is readable against its background (no white-on-white, WCAG AA 4.5:1)? +- [ ] **CTA Button Wrap**: no CTA label wraps to 2+ lines at desktop? +- [ ] **Form Contrast Check**: form inputs, placeholders, focus rings, labels all pass WCAG AA against the section background? +- [ ] **Serif discipline**: if a serif is used, it is NOT Fraunces or Instrument_Serif (or it is, with explicit brand justification)? Different serif from your previous project? +- [ ] **Premium-consumer palette check**: if the brief is premium-consumer (cookware / wellness / artisan / luxury), the palette is NOT the AI-default beige+brass+oxblood+espresso family? Different family from your previous premium-consumer project? +- [ ] **Italic descender clearance**: every italic word with `y g j p q` has `leading-[1.1]` min + `pb-1` reserve? +- [ ] **Hero fits the viewport**: headline ≤ 2 lines, subtext ≤ 20 words AND ≤ 4 lines, CTA visible without scroll, font scale planned around image? +- [ ] **Hero top padding**: max `pt-24` at desktop, hero content does not float halfway down the viewport? +- [ ] **Hero stack discipline**: max 4 text elements in hero (eyebrow OR brand strip, headline, subtext, CTAs)? No tiny tagline below CTAs, no trust micro-strip in hero? +- [ ] **EYEBROW COUNT (mechanical)**: count instances of `uppercase tracking` micro-labels above section headlines across all components. Count ≤ ceil(sectionCount / 3)? Hero counts as 1. +- [ ] **Split-Header Ban**: no "left big headline + right small explainer paragraph" pattern as a section header (vertical stack instead)? +- [ ] **Zigzag Alternation Cap**: no 3+ consecutive sections with the same image+text-split layout? +- [ ] **No Duplicate CTA Intent**: no two CTAs with the same intent ("Get in touch" + "Let's talk" both on page = Fail)? +- [ ] **Logo wall = logo only**: no industry / category labels printed below logos? +- [ ] **Bento Background Diversity**: at least 2-3 bento cells have real visual variation (image, gradient, pattern), not all white-on-white text cards? +- [ ] **"Used by / Trusted by" logo wall** lives UNDER the hero, not inside it, uses REAL SVG logos (Simple Icons / devicon) or generated SVG marks, NOT plain text wordmarks? +- [ ] **Copy Self-Audit**: every visible string re-read, no grammatically-broken or AI-hallucinated phrases ("free on its past" type) shipped? +- [ ] **Motion motivated**: every animation can be justified in one sentence (hierarchy / storytelling / feedback / state transition), no GSAP-for-show? +- [ ] **Marquee max-one-per-page**: no two horizontal marquees on the same page? +- [ ] **Navigation on ONE line** at desktop, height ≤ 80px? +- [ ] **Section-Layout-Repetition** check: no two sections share the same layout family (at least 4 different families across 8 sections)? +- [ ] **Bento has rhythm AND exact cell count** (N items → N cells, no empty cells in middle or at end)? +- [ ] **Long lists use the right UI component** (not default `
      ` with `divide-y` for > 5 items - see Section 4.9 alternatives)? +- [ ] **Real images used** (gen-tool first, then Picsum-seed, then explicit placeholder slots) - NO div-based fake screenshots, NO hand-rolled decorative SVGs, NO pure-text minimalism? +- [ ] **No pills/labels overlaid on images** (no `Plate · Brand`, no `Field notes - journal`)? +- [ ] **No photo-credit captions as decoration** (`Field study no. 12 · Ines Caetano`)? +- [ ] **No version footers** (`v1.4.2`, `Build 0048`) on marketing pages? +- [ ] **No micro-meta-sentences** under eyebrows ("Each of these is a feature we ship today...")? +- [ ] **No decoration text strip at hero bottom** (`BRAND. MOTION. SPATIAL.`)? +- [ ] **No floating top-right sub-text** in section headings? +- [ ] **No scoring/progress bars with filled background tracks** as comparison visuals? +- [ ] **No locale / city-name / time / weather strips** unless brief is genuinely globally-distributed or place-focused? +- [ ] **No scroll cues** (`Scroll`, `↓ scroll`, `Scroll to explore`)? +- [ ] **No version labels in hero** (V0.6, BETA, INVITE-ONLY) unless the brief is a launch? +- [ ] **No section-numbering eyebrows** (`00 / INDEX`, `001 · Capabilities`, `06 · how it works`)? +- [ ] **No decorative dots** (zero by default, only for real semantic state)? +- [ ] **No `border-t` + `border-b` on every row** of long lists / spec tables? +- [ ] **Content density** sane: no 20-row data tables, no fake-precise specs without justification, ≤ 25-word sub-paragraphs by default? +- [ ] **Quotes ≤ 3 lines** of body, attribution clean (no em-dash)? +- [ ] **Motion claimed = motion shown**: if `MOTION_INTENSITY > 4`, page actually animates, not just claimed? +- [ ] **GSAP sticky-stack / horizontal-pan** implemented per Section 5.A / 5.B canonical skeleton (`start: "top top"`, `pin: true`, correct scrub)? +- [ ] **No `window.addEventListener('scroll')`** - using Motion `useScroll()` / ScrollTrigger / IntersectionObserver / CSS scroll-driven animations only? +- [ ] **Reduced motion** wrapped for everything `MOTION_INTENSITY > 3`? +- [ ] **Dark mode** tokens defined and tested in both modes? +- [ ] **Mobile collapse** explicit (`w-full`, `px-4`, `max-w-7xl mx-auto`) for high-variance layouts? +- [ ] **Viewport stability**: `min-h-[100dvh]`, never `h-screen`? +- [ ] **`useEffect` animations** have strict cleanup functions? +- [ ] **Empty / loading / error** states provided? +- [ ] **Cards omitted** in favor of spacing where possible? +- [ ] **Icons** from an allowed library only (Phosphor / HugeIcons / Radix / Tabler), no hand-rolled SVG paths? +- [ ] **Motion** isolated in client-leaf components with `'use client'` at the top, memoized? +- [ ] **No AI Tells** from Section 9 (Inter as default, AI-purple, three-equal cards, Jane Doe, Acme, "Quietly in use at")? +- [ ] **Core Web Vitals** plausibly hit (LCP < 2.5s, INP < 200ms, CLS < 0.1)? +- [ ] **One design system** per project (no Material + shadcn mixed)? + +If a single checkbox cannot be honestly ticked, the page is not done. Fix it before delivering. + +--- + +# APPENDICES - Real Source-Backed Reference Material + +The sections below are vendored reference content. They give the agent real install commands, real canonical doc links, and real working starter snippets for each design system named in Section 2. Use them to ground decisions in production reality, not training-data fiction. + +## Appendix A - Install Commands per Design System + +```bash +# Material Web (Material 3) +npm install @material/web + +# Fluent UI React (v9) +npm install @fluentui/react-components + +# Fluent UI Web Components (framework-free) +npm install @fluentui/web-components @fluentui/tokens + +# IBM Carbon +npm install @carbon/react @carbon/styles + +# Radix Themes +npm install @radix-ui/themes + +# shadcn/ui (open code, owned components) +npx shadcn@latest init +npx shadcn@latest add button card badge separator input + +# Primer CSS (GitHub product/devtool UI) +npm install --save @primer/css + +# Primer Brand (GitHub marketing UI) +npm install @primer/react-brand + +# GOV.UK Frontend +npm install govuk-frontend + +# USWDS (US Web Design System) +npm install uswds + +# Atlassian Design System (Atlaskit) +yarn add @atlaskit/css-reset @atlaskit/tokens @atlaskit/button @atlaskit/badge @atlaskit/section-message @atlaskit/card + +# Bootstrap 5.3 +npm install bootstrap + +# Shopify Polaris Web Components (Shopify apps only) +# Add this to your app HTML head: +# +# +``` + +## Appendix B - Canonical Sources (read these before reinventing) + +### Material Web +- https://github.com/material-components/material-web +- https://material-web.dev/theming/material-theming/ +- https://m3.material.io/develop/web + +### Fluent UI +- https://fluent2.microsoft.design/get-started/develop +- https://fluent2.microsoft.design/components/web/react/ +- https://github.com/microsoft/fluentui +- https://learn.microsoft.com/en-us/fluent-ui/web-components/ + +### Carbon +- https://carbondesignsystem.com/ +- https://github.com/carbon-design-system/carbon +- https://carbondesignsystem.com/developing/react-tutorial/overview/ +- https://carbondesignsystem.com/developing/web-components-tutorial/overview/ + +### Shopify Polaris +- https://shopify.dev/docs/api/app-home/web-components +- https://github.com/Shopify/polaris-react +- https://polaris-react.shopify.com/components + +### Atlassian +- https://atlassian.design/get-started/develop +- https://atlassian.design/components/button/examples +- https://atlaskit.atlassian.com/packages/design-system/button/example/disabled +- https://atlassian.design/tokens/design-tokens + +### Primer +- https://primer.style/ +- https://github.com/primer/css +- https://github.com/primer/brand + +### GOV.UK +- https://design-system.service.gov.uk/components/button/ +- https://design-system.service.gov.uk/styles/layout/ +- https://github.com/alphagov/govuk-frontend + +### USWDS +- https://designsystem.digital.gov/documentation/developers/ +- https://designsystem.digital.gov/components/button/ +- https://designsystem.digital.gov/components/card/ +- https://github.com/uswds/uswds + +### Bootstrap +- https://getbootstrap.com/docs/5.3/layout/grid/ +- https://getbootstrap.com/docs/5.3/components/card/ + +### Tailwind +- https://tailwindcss.com/docs/dark-mode +- https://tailwindcss.com/blog/tailwindcss-v4 + +### Radix +- https://www.radix-ui.com/themes/docs/components/theme +- https://www.radix-ui.com/themes/docs/components/card +- https://github.com/radix-ui/themes + +### shadcn/ui +- https://ui.shadcn.com/docs +- https://ui.shadcn.com/docs/components/card +- https://github.com/shadcn-ui/ui + +### Native CSS / W3C standards +- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/backdrop-filter +- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-color-scheme +- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion +- https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout +- https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations +- https://drafts.csswg.org/scroll-animations-1/ + +### Apple Liquid Glass (Apple platforms only) +- https://developer.apple.com/design/human-interface-guidelines/materials +- https://developer.apple.com/documentation/TechnologyOverviews/liquid-glass +- https://developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass +- https://developer.apple.com/documentation/SwiftUI/Material + +--- + +## Appendix C - Apple Liquid Glass: Honest Web Approximation + +Do **not** treat random CSS snippets as official Apple Liquid Glass. + +### What is official +Apple documents Liquid Glass inside Apple's Human Interface Guidelines and Developer Documentation for **Apple platforms**. It is a dynamic material used across Apple platform UI. Apple's native implementation belongs to Apple platform APIs and system components, **not a public web CSS package**. + +Relevant official docs: +- Apple Human Interface Guidelines → Materials +- Apple Developer Documentation → Liquid Glass +- Apple Developer Documentation → Adopting Liquid Glass +- SwiftUI → Material + +### What is NOT official +There is no `liquid-glass.css` from Apple for normal websites. + +A web approximation can use: +- `backdrop-filter` +- transparent backgrounds +- layered borders +- highlight overlays +- gradients +- motion +- strong contrast fallbacks + +But that is **web glassmorphism / frosted-glass approximation**, not official Apple Liquid Glass. Label it as such in comments. + +### Safer web approximation skeleton + +```css +.liquid-glass-web-approx { + position: relative; + isolation: isolate; + overflow: hidden; + border-radius: 999px; + border: 1px solid rgb(255 255 255 / .32); + background: + linear-gradient(135deg, rgb(255 255 255 / .30), rgb(255 255 255 / .08)), + rgb(255 255 255 / .12); + backdrop-filter: blur(24px) saturate(180%) contrast(1.05); + -webkit-backdrop-filter: blur(24px) saturate(180%) contrast(1.05); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / .48), + inset 0 -1px 0 rgb(255 255 255 / .12), + 0 18px 60px rgb(0 0 0 / .18); +} + +.liquid-glass-web-approx::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + background: + radial-gradient(circle at 20% 0%, rgb(255 255 255 / .55), transparent 34%), + linear-gradient(90deg, rgb(255 255 255 / .18), transparent 42%, rgb(255 255 255 / .14)); + pointer-events: none; +} + +.liquid-glass-web-approx::after { + content: ""; + position: absolute; + inset: 1px; + border-radius: inherit; + border: 1px solid rgb(255 255 255 / .14); + pointer-events: none; +} + +@media (prefers-color-scheme: dark) { + .liquid-glass-web-approx { + border-color: rgb(255 255 255 / .18); + background: + linear-gradient(135deg, rgb(255 255 255 / .16), rgb(255 255 255 / .04)), + rgb(15 23 42 / .42); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / .22), + 0 18px 60px rgb(0 0 0 / .42); + } +} + +@media (prefers-reduced-transparency: reduce) { + .liquid-glass-web-approx { + background: rgb(255 255 255 / .96); + backdrop-filter: none; + -webkit-backdrop-filter: none; + } +} +``` + +**Important:** `prefers-reduced-transparency` has uneven browser support; test it. Always provide enough contrast even without blur. + +--- + +**End of appendices.** Install commands above are reality anchors. The Apple Liquid Glass skeleton is a labeled approximation, not an Apple-issued package. For canonical docs per design system, consult the system's official docs (links in Section 2 plus Appendix B). diff --git a/.agents/skills/emil-design-eng/SKILL.md b/.agents/skills/emil-design-eng/SKILL.md new file mode 100644 index 00000000000..49112353252 --- /dev/null +++ b/.agents/skills/emil-design-eng/SKILL.md @@ -0,0 +1,679 @@ +--- +name: emil-design-eng +description: This skill encodes Emil Kowalski's philosophy on UI polish, component design, animation decisions, and the invisible details that make software feel great. +--- + +# Design Engineering + +## Initial Response + +When this skill is first invoked without a specific question, respond only with: + +> I'm ready to help you build interfaces that feel right, my knowledge comes from Emil Kowalski's design engineering philosophy. If you want to dive even deeper, check out Emil’s course: [animations.dev](https://animations.dev/). + +Do not provide any other information until the user asks a question. + +You are a design engineer with the craft sensibility. You build interfaces where every detail compounds into something that feels right. You understand that in a world where everyone's software is good enough, taste is the differentiator. + +## Core Philosophy + +### Taste is trained, not innate + +Good taste is not personal preference. It is a trained instinct: the ability to see beyond the obvious and recognize what elevates. You develop it by surrounding yourself with great work, thinking deeply about why something feels good, and practicing relentlessly. + +When building UI, don't just make it work. Study why the best interfaces feel the way they do. Reverse engineer animations. Inspect interactions. Be curious. + +### Unseen details compound + +Most details users never consciously notice. That is the point. When a feature functions exactly as someone assumes it should, they proceed without giving it a second thought. That is the goal. + +> "All those unseen details combine to produce something that's just stunning, like a thousand barely audible voices all singing in tune." - Paul Graham + +Every decision below exists because the aggregate of invisible correctness creates interfaces people love without knowing why. + +### Beauty is leverage + +People select tools based on the overall experience, not just functionality. Good defaults and good animations are real differentiators. Beauty is underutilized in software. Use it as leverage to stand out. + +## Review Format (Required) + +When reviewing UI code, you MUST use a markdown table with Before/After columns. Do NOT use a list with "Before:" and "After:" on separate lines. Always output an actual markdown table like this: + +| Before | After | Why | +| --- | --- | --- | +| `transition: all 300ms` | `transition: transform 200ms ease-out` | Specify exact properties; avoid `all` | +| `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing in the real world appears from nothing | +| `ease-in` on dropdown | `ease-out` with custom curve | `ease-in` feels sluggish; `ease-out` gives instant feedback | +| No `:active` state on button | `transform: scale(0.97)` on `:active` | Buttons must feel responsive to press | +| `transform-origin: center` on popover | `transform-origin: var(--radix-popover-content-transform-origin)` | Popovers should scale from their trigger (not modals — modals stay centered) | + +Wrong format (never do this): + +``` +Before: transition: all 300ms +After: transition: transform 200ms ease-out +──────────────────────────── +Before: scale(0) +After: scale(0.95) +``` + +Correct format: A single markdown table with | Before | After | Why | columns, one row per issue found. The "Why" column briefly explains the reasoning. + +## The Animation Decision Framework + +Before writing any animation code, answer these questions in order: + +### 1. Should this animate at all? + +**Ask:** How often will users see this animation? + +| Frequency | Decision | +| ----------------------------------------------------------- | ---------------------------- | +| 100+ times/day (keyboard shortcuts, command palette toggle) | No animation. Ever. | +| Tens of times/day (hover effects, list navigation) | Remove or drastically reduce | +| Occasional (modals, drawers, toasts) | Standard animation | +| Rare/first-time (onboarding, feedback forms, celebrations) | Can add delight | + +**Never animate keyboard-initiated actions.** These actions are repeated hundreds of times daily. Animation makes them feel slow, delayed, and disconnected from the user's actions. + +Raycast has no open/close animation. That is the optimal experience for something used hundreds of times a day. + +### 2. What is the purpose? + +Every animation must have a clear answer to "why does this animate?" + +Valid purposes: + +- **Spatial consistency**: toast enters and exits from the same direction, making swipe-to-dismiss feel intuitive +- **State indication**: a morphing feedback button shows the state change +- **Explanation**: a marketing animation that shows how a feature works +- **Feedback**: a button scales down on press, confirming the interface heard the user +- **Preventing jarring changes**: elements appearing or disappearing without transition feel broken + +If the purpose is just "it looks cool" and the user will see it often, don't animate. + +### 3. What easing should it use? + +Is the element entering or exiting? + Yes → ease-out (starts fast, feels responsive) + No → + Is it moving/morphing on screen? + Yes → ease-in-out (natural acceleration/deceleration) + Is it a hover/color change? + Yes → ease + Is it constant motion (marquee, progress bar)? + Yes → linear + Default → ease-out + +**Critical: use custom easing curves.** The built-in CSS easings are too weak. They lack the punch that makes animations feel intentional. + +```css +/* Strong ease-out for UI interactions */ +--ease-out: cubic-bezier(0.23, 1, 0.32, 1); + +/* Strong ease-in-out for on-screen movement */ +--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); + +/* iOS-like drawer curve (from Ionic Framework) */ +--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); +``` + +**Never use ease-in for UI animations.** It starts slow, which makes the interface feel sluggish and unresponsive. A dropdown with `ease-in` at 300ms _feels_ slower than `ease-out` at the same 300ms, because ease-in delays the initial movement — the exact moment the user is watching most closely. + +**Easing curve resources:** Don't create curves from scratch. Use [easing.dev](https://easing.dev/) or [easings.co](https://easings.co/) to find stronger custom variants of standard easings. + +### 4. How fast should it be? + +| Element | Duration | +| ------------------------ | ------------- | +| Button press feedback | 100-160ms | +| Tooltips, small popovers | 125-200ms | +| Dropdowns, selects | 150-250ms | +| Modals, drawers | 200-500ms | +| Marketing/explanatory | Can be longer | + +**Rule: UI animations should stay under 300ms.** A 180ms dropdown feels more responsive than a 400ms one. A faster-spinning spinner makes the app feel like it loads faster, even when the load time is identical. + +### Perceived performance + +Speed in animation is not just about feeling snappy — it directly affects how users perceive your app's performance: + +- A **fast-spinning spinner** makes loading feel faster (same load time, different perception) +- A **180ms select** animation feels more responsive than a **400ms** one +- **Instant tooltips** after the first one is open (skip delay + skip animation) make the whole toolbar feel faster + +The perception of speed matters as much as actual speed. Easing amplifies this: `ease-out` at 200ms _feels_ faster than `ease-in` at 200ms because the user sees immediate movement. + +## Spring Animations + +Springs feel more natural than duration-based animations because they simulate real physics. They don't have fixed durations — they settle based on physical parameters. + +### When to use springs + +- Drag interactions with momentum +- Elements that should feel "alive" (like Apple's Dynamic Island) +- Gestures that can be interrupted mid-animation +- Decorative mouse-tracking interactions + +### Spring-based mouse interactions + +Tying visual changes directly to mouse position feels artificial because it lacks motion. Use `useSpring` from Motion (formerly Framer Motion) to interpolate value changes with spring-like behavior instead of updating immediately. + +```jsx +import { useSpring } from 'framer-motion'; + +// Without spring: feels artificial, instant +const rotation = mouseX * 0.1; + +// With spring: feels natural, has momentum +const springRotation = useSpring(mouseX * 0.1, { + stiffness: 100, + damping: 10, +}); +``` + +This works because the animation is **decorative** — it doesn't serve a function. If this were a functional graph in a banking app, no animation would be better. Know when decoration helps and when it hinders. + +### Spring configuration + +**Apple's approach (recommended — easier to reason about):** + +```js +{ type: "spring", duration: 0.5, bounce: 0.2 } +``` + +**Traditional physics (more control):** + +```js +{ type: "spring", mass: 1, stiffness: 100, damping: 10 } +``` + +Keep bounce subtle (0.1-0.3) when used. Avoid bounce in most UI contexts. Use it for drag-to-dismiss and playful interactions. + +### Interruptibility advantage + +Springs maintain velocity when interrupted — CSS animations and keyframes restart from zero. This makes springs ideal for gestures users might change mid-motion. When you click an expanded item and quickly press Escape, a spring-based animation smoothly reverses from its current position. + +## Component Building Principles + +### Buttons must feel responsive + +Add `transform: scale(0.97)` on `:active`. This gives instant feedback, making the UI feel like it is truly listening to the user. + +```css +.button { + transition: transform 160ms ease-out; +} + +.button:active { + transform: scale(0.97); +} +``` + +This applies to any pressable element. The scale should be subtle (0.95-0.98). + +### Never animate from scale(0) + +Nothing in the real world disappears and reappears completely. Elements animating from `scale(0)` look like they come out of nowhere. + +Start from `scale(0.9)` or higher, combined with opacity. Even a barely-visible initial scale makes the entrance feel more natural, like a balloon that has a visible shape even when deflated. + +```css +/* Bad */ +.entering { + transform: scale(0); +} + +/* Good */ +.entering { + transform: scale(0.95); + opacity: 0; +} +``` + +### Make popovers origin-aware + +Popovers should scale in from their trigger, not from center. The default `transform-origin: center` is wrong for almost every popover. **Exception: modals.** Modals should keep `transform-origin: center` because they are not anchored to a specific trigger — they appear centered in the viewport. + +```css +/* Radix UI */ +.popover { + transform-origin: var(--radix-popover-content-transform-origin); +} + +/* Base UI */ +.popover { + transform-origin: var(--transform-origin); +} +``` + +Whether the user notices the difference individually does not matter. In the aggregate, unseen details become visible. They compound. + +### Tooltips: skip delay on subsequent hovers + +Tooltips should delay before appearing to prevent accidental activation. But once one tooltip is open, hovering over adjacent tooltips should open them instantly with no animation. This feels faster without defeating the purpose of the initial delay. + +```css +.tooltip { + transition: transform 125ms ease-out, opacity 125ms ease-out; + transform-origin: var(--transform-origin); +} + +.tooltip[data-starting-style], +.tooltip[data-ending-style] { + opacity: 0; + transform: scale(0.97); +} + +/* Skip animation on subsequent tooltips */ +.tooltip[data-instant] { + transition-duration: 0ms; +} +``` + +### Use CSS transitions over keyframes for interruptible UI + +CSS transitions can be interrupted and retargeted mid-animation. Keyframes restart from zero. For any interaction that can be triggered rapidly (adding toasts, toggling states), transitions produce smoother results. + +```css +/* Interruptible - good for UI */ +.toast { + transition: transform 400ms ease; +} + +/* Not interruptible - avoid for dynamic UI */ +@keyframes slideIn { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} +``` + +### Use blur to mask imperfect transitions + +When a crossfade between two states feels off despite trying different easings and durations, add subtle `filter: blur(2px)` during the transition. + +**Why blur works:** Without blur, you see two distinct objects during a crossfade — the old state and the new state overlapping. This looks unnatural. Blur bridges the visual gap by blending the two states together, tricking the eye into perceiving a single smooth transformation instead of two objects swapping. + +Combine blur with scale-on-press (`scale(0.97)`) for a polished button state transition: + +```css +.button { + transition: transform 160ms ease-out; +} + +.button:active { + transform: scale(0.97); +} + +.button-content { + transition: filter 200ms ease, opacity 200ms ease; +} + +.button-content.transitioning { + filter: blur(2px); + opacity: 0.7; +} +``` + +Keep blur under 20px. Heavy blur is expensive, especially in Safari. + +### Animate enter states with @starting-style + +The modern CSS way to animate element entry without JavaScript: + +```css +.toast { + opacity: 1; + transform: translateY(0); + transition: opacity 400ms ease, transform 400ms ease; + + @starting-style { + opacity: 0; + transform: translateY(100%); + } +} +``` + +This replaces the common React pattern of using `useEffect` to set `mounted: true` after initial render. Use `@starting-style` when browser support allows; fall back to the `data-mounted` attribute pattern otherwise. + +```jsx +// Legacy pattern (still works everywhere) +useEffect(() => { + setMounted(true); +}, []); +//
      +``` + +## CSS Transform Mastery + +### translateY with percentages + +Percentage values in `translate()` are relative to the element's own size. Use `translateY(100%)` to move an element by its own height, regardless of actual dimensions. This is how Sonner positions toasts and how Vaul hides the drawer before animating in. + +```css +/* Works regardless of drawer height */ +.drawer-hidden { + transform: translateY(100%); +} + +/* Works regardless of toast height */ +.toast-enter { + transform: translateY(-100%); +} +``` + +Prefer percentages over hardcoded pixel values. They are less error-prone and adapt to content. + +### scale() scales children too + +Unlike `width`/`height`, `scale()` also scales an element's children. When scaling a button on press, the font size, icons, and content scale proportionally. This is a feature, not a bug. + +### 3D transforms for depth + +`rotateX()`, `rotateY()` with `transform-style: preserve-3d` create real 3D effects in CSS. Orbiting animations, coin flips, and depth effects are all possible without JavaScript. + +```css +.wrapper { + transform-style: preserve-3d; +} + +@keyframes orbit { + from { + transform: translate(-50%, -50%) rotateY(0deg) translateZ(72px) rotateY(360deg); + } + to { + transform: translate(-50%, -50%) rotateY(360deg) translateZ(72px) rotateY(0deg); + } +} +``` + +### transform-origin + +Every element has an anchor point from which transforms execute. The default is center. Set it to match where the trigger lives for origin-aware interactions. + +## clip-path for Animation + +`clip-path` is not just for shapes. It is one of the most powerful animation tools in CSS. + +### The inset shape + +`clip-path: inset(top right bottom left)` defines a rectangular clipping region. Each value "eats" into the element from that side. + +```css +/* Fully hidden from right */ +.hidden { + clip-path: inset(0 100% 0 0); +} + +/* Fully visible */ +.visible { + clip-path: inset(0 0 0 0); +} + +/* Reveal from left to right */ +.overlay { + clip-path: inset(0 100% 0 0); + transition: clip-path 200ms ease-out; +} +.button:active .overlay { + clip-path: inset(0 0 0 0); + transition: clip-path 2s linear; +} +``` + +### Tabs with perfect color transitions + +Duplicate the tab list. Style the copy as "active" (different background, different text color). Clip the copy so only the active tab is visible. Animate the clip on tab change. This creates a seamless color transition that timing individual color transitions can never achieve. + +### Hold-to-delete pattern + +Use `clip-path: inset(0 100% 0 0)` on a colored overlay. On `:active`, transition to `inset(0 0 0 0)` over 2s with linear timing. On release, snap back with 200ms ease-out. Add `scale(0.97)` on the button for press feedback. + +### Image reveals on scroll + +Start with `clip-path: inset(0 0 100% 0)` (hidden from bottom). Animate to `inset(0 0 0 0)` when the element enters the viewport. Use `IntersectionObserver` or Framer Motion's `useInView` with `{ once: true, margin: "-100px" }`. + +### Comparison sliders + +Overlay two images. Clip the top one with `clip-path: inset(0 50% 0 0)`. Adjust the right inset value based on drag position. No extra DOM elements needed, fully hardware-accelerated. + +## Gesture and Drag Interactions + +### Momentum-based dismissal + +Don't require dragging past a threshold. Calculate velocity: `Math.abs(dragDistance) / elapsedTime`. If velocity exceeds ~0.11, dismiss regardless of distance. A quick flick should be enough. + +```js +const timeTaken = new Date().getTime() - dragStartTime.current.getTime(); +const velocity = Math.abs(swipeAmount) / timeTaken; + +if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) { + dismiss(); +} +``` + +### Damping at boundaries + +When a user drags past the natural boundary (e.g., dragging a drawer up when already at top), apply damping. The more they drag, the less the element moves. Things in real life don't suddenly stop; they slow down first. + +### Pointer capture for drag + +Once dragging starts, set the element to capture all pointer events. This ensures dragging continues even if the pointer leaves the element bounds. + +### Multi-touch protection + +Ignore additional touch points after the initial drag begins. Without this, switching fingers mid-drag causes the element to jump to the new position. + +```js +function onPress() { + if (isDragging) return; + // Start drag... +} +``` + +### Friction instead of hard stops + +Instead of preventing upward drag entirely, allow it with increasing friction. It feels more natural than hitting an invisible wall. + +## Performance Rules + +### Only animate transform and opacity + +These properties skip layout and paint, running on the GPU. Animating `padding`, `margin`, `height`, or `width` triggers all three rendering steps. + +### CSS variables are inheritable + +Changing a CSS variable on a parent recalculates styles for all children. In a drawer with many items, updating `--swipe-amount` on the container causes expensive style recalculation. Update `transform` directly on the element instead. + +```js +// Bad: triggers recalc on all children +element.style.setProperty('--swipe-amount', `${distance}px`); + +// Good: only affects this element +element.style.transform = `translateY(${distance}px)`; +``` + +### Framer Motion hardware acceleration caveat + +Framer Motion's shorthand properties (`x`, `y`, `scale`) are NOT hardware-accelerated. They use `requestAnimationFrame` on the main thread. For hardware acceleration, use the full `transform` string: + +```jsx +// NOT hardware accelerated (convenient but drops frames under load) + + +// Hardware accelerated (stays smooth even when main thread is busy) + +``` + +This matters when the browser is simultaneously loading content, running scripts, or painting. At Vercel, the dashboard tab animation used Shared Layout Animations and dropped frames during page loads. Switching to CSS animations (off main thread) fixed it. + +### CSS animations beat JS under load + +CSS animations run off the main thread. When the browser is busy loading a new page, Framer Motion animations (using `requestAnimationFrame`) drop frames. CSS animations remain smooth. Use CSS for predetermined animations; JS for dynamic, interruptible ones. + +### Use WAAPI for programmatic CSS animations + +The Web Animations API gives you JavaScript control with CSS performance. Hardware-accelerated, interruptible, and no library needed. + +```js +element.animate([{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0 0)' }], { + duration: 1000, + fill: 'forwards', + easing: 'cubic-bezier(0.77, 0, 0.175, 1)', +}); +``` + +## Accessibility + +### prefers-reduced-motion + +Animations can cause motion sickness. Reduced motion means fewer and gentler animations, not zero. Keep opacity and color transitions that aid comprehension. Remove movement and position animations. + +```css +@media (prefers-reduced-motion: reduce) { + .element { + animation: fade 0.2s ease; + /* No transform-based motion */ + } +} +``` + +```jsx +const shouldReduceMotion = useReducedMotion(); +const closedX = shouldReduceMotion ? 0 : '-100%'; +``` + +### Touch device hover states + +```css +@media (hover: hover) and (pointer: fine) { + .element:hover { + transform: scale(1.05); + } +} +``` + +Touch devices trigger hover on tap, causing false positives. Gate hover animations behind this media query. + +## The Sonner Principles (Building Loved Components) + +These principles come from building Sonner (13M+ weekly npm downloads) and apply to any component: + +1. **Developer experience is key.** No hooks, no context, no complex setup. Insert `` once, call `toast()` from anywhere. The less friction to adopt, the more people will use it. + +2. **Good defaults matter more than options.** Ship beautiful out of the box. Most users never customize. The default easing, timing, and visual design should be excellent. + +3. **Naming creates identity.** "Sonner" (French for "to ring") feels more elegant than "react-toast". Sacrifice discoverability for memorability when appropriate. + +4. **Handle edge cases invisibly.** Pause toast timers when the tab is hidden. Fill gaps between stacked toasts with pseudo-elements to maintain hover state. Capture pointer events during drag. Users never notice these, and that is exactly right. + +5. **Use transitions, not keyframes, for dynamic UI.** Toasts are added rapidly. Keyframes restart from zero on interruption. Transitions retarget smoothly. + +6. **Build a great documentation site.** Let people touch the product, play with it, and understand it before they use it. Interactive examples with ready-to-use code snippets lower the barrier to adoption. + +### Cohesion matters + +Sonner's animation feels satisfying partly because the whole experience is cohesive. The easing and duration fit the vibe of the library. It is slightly slower than typical UI animations and uses `ease` rather than `ease-out` to feel more elegant. The animation style matches the toast design, the page design, the name — everything is in harmony. + +When choosing animation values, consider the personality of the component. A playful component can be bouncier. A professional dashboard should be crisp and fast. Match the motion to the mood. + +### The opacity + height combination + +When items enter and exit a list (like Family's drawer), the opacity change must work well with the height animation. This is often trial and error. There is no formula — you adjust until it feels right. + +### Review your work the next day + +Review animations with fresh eyes. You notice imperfections the next day that you missed during development. Play animations in slow motion or frame by frame to spot timing issues that are invisible at full speed. + +### Asymmetric enter/exit timing + +Pressing should be slow when it needs to be deliberate (hold-to-delete: 2s linear), but release should always be snappy (200ms ease-out). This pattern applies broadly: slow where the user is deciding, fast where the system is responding. + +```css +/* Release: fast */ +.overlay { + transition: clip-path 200ms ease-out; +} + +/* Press: slow and deliberate */ +.button:active .overlay { + transition: clip-path 2s linear; +} +``` + +## Stagger Animations + +When multiple elements enter together, stagger their appearance. Each element animates in with a small delay after the previous one. This creates a cascading effect that feels more natural than everything appearing at once. + +```css +.item { + opacity: 0; + transform: translateY(8px); + animation: fadeIn 300ms ease-out forwards; +} + +.item:nth-child(1) { + animation-delay: 0ms; +} +.item:nth-child(2) { + animation-delay: 50ms; +} +.item:nth-child(3) { + animation-delay: 100ms; +} +.item:nth-child(4) { + animation-delay: 150ms; +} + +@keyframes fadeIn { + to { + opacity: 1; + transform: translateY(0); + } +} +``` + +Keep stagger delays short (30-80ms between items). Long delays make the interface feel slow. Stagger is decorative — never block interaction while stagger animations are playing. + +## Debugging Animations + +### Slow motion testing + +Play animations at reduced speed to spot issues invisible at full speed. Temporarily increase duration to 2-5x normal, or use browser DevTools animation inspector to slow playback. + +Things to look for in slow motion: + +- Do colors transition smoothly, or do you see two distinct states overlapping? +- Does the easing feel right, or does it start/stop abruptly? +- Is the transform-origin correct, or does the element scale from the wrong point? +- Are multiple animated properties (opacity, transform, color) in sync? + +### Frame-by-frame inspection + +Step through animations frame by frame in Chrome DevTools (Animations panel). This reveals timing issues between coordinated properties that you cannot see at full speed. + +### Test on real devices + +For touch interactions (drawers, swipe gestures), test on physical devices. Connect your phone via USB, visit your local dev server by IP address, and use Safari's remote devtools. The Xcode Simulator is an alternative but real hardware is better for gesture testing. + +## Review Checklist + +When reviewing UI code, check for: + +| Issue | Fix | +| ------------------------------------------ | ---------------------------------------------------------------- | +| `transition: all` | Specify exact properties: `transition: transform 200ms ease-out` | +| `scale(0)` entry animation | Start from `scale(0.95)` with `opacity: 0` | +| `ease-in` on UI element | Switch to `ease-out` or custom curve | +| `transform-origin: center` on popover | Set to trigger location or use Radix/Base UI CSS variable (modals are exempt — keep centered) | +| Animation on keyboard action | Remove animation entirely | +| Duration > 300ms on UI element | Reduce to 150-250ms | +| Hover animation without media query | Add `@media (hover: hover) and (pointer: fine)` | +| Keyframes on rapidly-triggered element | Use CSS transitions for interruptibility | +| Framer Motion `x`/`y` props under load | Use `transform: "translateX()"` for hardware acceleration | +| Same enter/exit transition speed | Make exit faster than enter (e.g., enter 2s, exit 200ms) | +| Elements all appear at once | Add stagger delay (30-80ms between items) | diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md index 4ff595cce9e..93f0997320a 100644 --- a/.claude/commands/add-block.md +++ b/.claude/commands/add-block.md @@ -732,6 +732,13 @@ Please provide the SVG and I'll convert it to a React component. You can usually find this in the service's brand/press kit page, or copy it from their website. ``` +When converting the SVG: a **monochrome** logo (single white or black mark) must +use `fill='currentColor'`, never a hardcoded `#fff`/`#000000`. Block icons render +both inside their `bgColor` tile and "bare" on a neutral page (the home Suggested +actions list) in light and dark mode; a hardcoded white/black mark goes invisible +bare on the matching background. Multi-color brand logos keep their own fills. +Verify with `bun run check:bare-icons`. + ## Advanced Mode for Optional Fields Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. This includes: diff --git a/.claude/commands/add-integration.md b/.claude/commands/add-integration.md index 3fd58eff52e..6a379857c50 100644 --- a/.claude/commands/add-integration.md +++ b/.claude/commands/add-integration.md @@ -279,6 +279,31 @@ Once the user provides the SVG: 2. Create a React component that spreads props 3. Ensure viewBox is preserved from the original SVG +### Theme-safety (bare rendering) — REQUIRED + +The icon renders both inside its colored `bgColor` tile AND "bare" (no tile) on a +neutral page — e.g. the home **Suggested actions** list — in both light and dark +mode. A monochrome logo whose paths hardcode a single near-white or near-black +fill is invisible bare on the matching background (white-on-white in light mode, +black-on-black in dark mode). + +Rules when adding the SVG: + +- **Monochrome logos** (a single white or black mark): draw the shape with + `fill='currentColor'`, not `fill='#fff'` / `fill='#000000'`. It then inherits + white inside dark tiles, near-black inside light tiles (via + `getTileIconColorClass`), and the theme-aware `var(--text-icon)` bare — legible + everywhere. Do NOT set `iconColor` for these. +- **Multi-color brand logos** (their own vivid fills): keep the hardcoded fills. + They read on any background. Only set `iconColor` (a vivid brand hex, never a + near-black/near-white tile color) if the bare icon should adopt a brand tint. +- A large white shape with a tiny vivid accent (e.g. a logo where the body is the + white negative space) still vanishes bare — convert the body to `currentColor`. + +Verify with `bun run check:bare-icons` (also runs in CI). It flags purely +monochrome hazards; for partial-accent logos, eyeball the suggested-actions list +in both light and dark mode. + ## Step 5: Create Triggers (Optional) If the service supports webhooks, create triggers using the generic `buildTriggerSubBlocks` helper. @@ -466,6 +491,7 @@ If creating V2 versions (API-aligned outputs): - [ ] Asked user to provide SVG - [ ] Added icon to `components/icons.tsx` - [ ] Icon spreads props correctly +- [ ] Monochrome marks use `fill='currentColor'` (not hardcoded white/black) so the icon renders bare in light AND dark mode — verified with `bun run check:bare-icons` ### Triggers (if service supports webhooks) - [ ] Created `triggers/{service}/` directory diff --git a/.claude/skills/design-taste-frontend b/.claude/skills/design-taste-frontend new file mode 120000 index 00000000000..1e36b661b52 --- /dev/null +++ b/.claude/skills/design-taste-frontend @@ -0,0 +1 @@ +../../.agents/skills/design-taste-frontend \ No newline at end of file diff --git a/.claude/skills/emil-design-eng b/.claude/skills/emil-design-eng new file mode 120000 index 00000000000..0f0ee981cfe --- /dev/null +++ b/.claude/skills/emil-design-eng @@ -0,0 +1 @@ +../../.agents/skills/emil-design-eng \ No newline at end of file diff --git a/.cursor/rules/constitution.mdc b/.cursor/rules/constitution.mdc index 94186db6e3a..dbce52e5298 100644 --- a/.cursor/rules/constitution.mdc +++ b/.cursor/rules/constitution.mdc @@ -1,6 +1,6 @@ --- description: Sim product language, positioning, and tone guidelines -globs: ["apps/sim/app/(landing)/**", "apps/sim/app/(home)/**", "apps/docs/**", "apps/sim/app/manifest.ts", "apps/sim/app/sitemap.ts", "apps/sim/app/robots.ts", "apps/sim/app/llms.txt/**", "apps/sim/app/llms-full.txt/**", "apps/sim/app/(landing)/**/structured-data*", "apps/docs/**/structured-data*", "**/metadata*", "**/seo*"] +globs: ["apps/sim/app/(landing)/**", "apps/docs/**", "apps/sim/app/manifest.ts", "apps/sim/app/sitemap.ts", "apps/sim/app/robots.ts", "apps/sim/app/llms.txt/**", "apps/sim/app/llms-full.txt/**", "apps/sim/app/(landing)/**/structured-data*", "apps/docs/**/structured-data*", "**/metadata*", "**/seo*"] --- # Sim — Language & Positioning diff --git a/.cursor/rules/landing-seo-geo.mdc b/.cursor/rules/landing-seo-geo.mdc index 4ec16754b99..c078b175d22 100644 --- a/.cursor/rules/landing-seo-geo.mdc +++ b/.cursor/rules/landing-seo-geo.mdc @@ -1,6 +1,6 @@ --- description: SEO and GEO guidelines for the landing page -globs: ["apps/sim/app/(home)/**/*.tsx"] +globs: ["apps/sim/app/(landing)/**/*.tsx"] --- # Landing Page — SEO / GEO diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index c4ffd7449ea..29c1058b0b0 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -125,6 +125,9 @@ jobs: - name: Client boundary import audit run: bun run check:client-boundary + - name: Bare-icon theme-safety audit + run: bun run check:bare-icons + - name: Verify realtime prune graph run: bun run check:realtime-prune diff --git a/apps/sim/app/(auth)/auth-layout-client.tsx b/apps/sim/app/(auth)/auth-layout-client.tsx index 89aeb3a89e7..82dbf7ef7cc 100644 --- a/apps/sim/app/(auth)/auth-layout-client.tsx +++ b/apps/sim/app/(auth)/auth-layout-client.tsx @@ -1,27 +1,5 @@ -'use client' - -import { useEffect } from 'react' -import AuthBackground from '@/app/(auth)/components/auth-background' -import Navbar from '@/app/(landing)/components/navbar/navbar' +import { AuthShell } from '@/app/(auth)/components' export default function AuthLayoutClient({ children }: { children: React.ReactNode }) { - useEffect(() => { - document.documentElement.classList.add('dark') - return () => { - document.documentElement.classList.remove('dark') - } - }, []) - - return ( - -
      -
      - -
      -
      -
      {children}
      -
      -
      -
      - ) + return {children} } diff --git a/apps/sim/app/(auth)/components/auth-divider.tsx b/apps/sim/app/(auth)/components/auth-divider.tsx new file mode 100644 index 00000000000..f995280a4f0 --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-divider.tsx @@ -0,0 +1,21 @@ +interface AuthDividerProps { + label: string +} + +/** + * The "Or continue with" rule separating the email/password form from the + * social/SSO options. Light tokens only: a `--border` hairline with the label + * knocked out over the `--bg` canvas in `--text-muted`. + */ +export function AuthDivider({ label }: AuthDividerProps) { + return ( +
      +
      +
      +
      +
      + {label} +
      +
      + ) +} diff --git a/apps/sim/app/(auth)/components/auth-field.tsx b/apps/sim/app/(auth)/components/auth-field.tsx new file mode 100644 index 00000000000..e6b53d0edae --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-field.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react' +import { Label } from '@sim/emcn' + +interface AuthFieldProps { + /** Matches the `id` set on the control rendered as {@link children}. */ + htmlFor: string + label: string + /** Validation messages to render beneath the control. */ + errors?: string[] + /** Optional right-aligned action shown next to the label (e.g. Forgot password). */ + action?: ReactNode + /** The field control — a {@link ChipInput}/{@link PasswordInput}. */ + children: ReactNode +} + +/** + * A labeled form field row: canonical {@link Label}, an optional inline label + * action, the control, and a validation-message list in the error token. The + * control drives its own invalid chrome through its `error` prop — this wrapper + * only owns the label row and the message list, so every auth field reads and + * spaces identically. + */ +export function AuthField({ htmlFor, label, errors, action, children }: AuthFieldProps) { + const hasErrors = Boolean(errors && errors.length > 0) + return ( +
      +
      + + {action} +
      + {children} + {hasErrors && ( +
      + {errors?.map((error) => ( +

      {error}

      + ))} +
      + )} +
      + ) +} diff --git a/apps/sim/app/(auth)/components/auth-form-message.tsx b/apps/sim/app/(auth)/components/auth-form-message.tsx new file mode 100644 index 00000000000..11e4930968c --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-form-message.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' + +interface AuthFormMessageProps { + type: 'error' | 'success' + align?: 'left' | 'center' + children: ReactNode +} + +/** + * Form-level status copy (not tied to a single field) in the canonical tokens: + * errors in `--text-error`, success in `--brand-accent`. One place owns the + * auth message chrome so success/error states never drift to ad-hoc hex or + * `text-red-*`/`#4CAF50` colors. + */ +export function AuthFormMessage({ type, align = 'left', children }: AuthFormMessageProps) { + return ( +
      + {children} +
      + ) +} diff --git a/apps/sim/app/(auth)/components/auth-header.tsx b/apps/sim/app/(auth)/components/auth-header.tsx new file mode 100644 index 00000000000..96cfceceda5 --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-header.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react' + +interface AuthHeaderProps { + title: string + description: ReactNode +} + +/** + * The centered heading + subcopy block shared by every auth page and status + * page. One source of truth for auth heading typography (light tokens, normal + * weight, no bespoke tracking — aligned with the landing scale, sized down for + * the single-column form). + */ +export function AuthHeader({ title, description }: AuthHeaderProps) { + return ( +
      +

      {title}

      +

      {description}

      +
      + ) +} diff --git a/apps/sim/app/(auth)/components/auth-input.tsx b/apps/sim/app/(auth)/components/auth-input.tsx new file mode 100644 index 00000000000..396bae64cf8 --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-input.tsx @@ -0,0 +1,20 @@ +'use client' + +import * as React from 'react' +import { ChipInput, type ChipInputProps, cn } from '@sim/emcn' +import { AUTH_CONTROL_HEIGHT } from '@/app/(auth)/components/constants' + +/** + * The auth text field — a {@link ChipInput} raised to the auth control height + * ({@link AUTH_CONTROL_HEIGHT}) so every labeled field on the auth and invite + * surfaces shares one slightly-taller geometry. All chip props pass through + * (`error`, `endAdornment`, `icon`, …); only the height is owned here, and a + * caller's `className` (layout only) still composes on top. + */ +export const AuthInput = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +) + +AuthInput.displayName = 'AuthInput' diff --git a/apps/sim/app/(auth)/components/auth-legal-footer.tsx b/apps/sim/app/(auth)/components/auth-legal-footer.tsx new file mode 100644 index 00000000000..a5a80e698f1 --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-legal-footer.tsx @@ -0,0 +1,26 @@ +import { AuthTextLink } from '@/app/(auth)/components/auth-text-link' + +interface AuthLegalFooterProps { + /** The gerund describing the consent action, e.g. "signing in". */ + action: string +} + +/** + * The "By {action}, you agree to our Terms / Privacy" fine print shared by the + * login and signup pages. Restyled to muted light tokens with the legal links + * routed through {@link AuthTextLink}, so the consent copy has one source. + */ +export function AuthLegalFooter({ action }: AuthLegalFooterProps) { + return ( +

      + By {action}, you agree to our{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + +

      + ) +} diff --git a/apps/sim/app/(auth)/components/auth-nav-prompt.tsx b/apps/sim/app/(auth)/components/auth-nav-prompt.tsx new file mode 100644 index 00000000000..d479a273b4a --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-nav-prompt.tsx @@ -0,0 +1,27 @@ +import { ChipLink } from '@sim/emcn' + +interface AuthNavPromptProps { + /** Muted lead text before the link (e.g. "Don't have an account?"). */ + prompt?: string + href: string + linkLabel: string + /** Side effect to run before navigation (e.g. clearing verification state). */ + onNavigate?: () => void +} + +/** + * The cross-page navigation row (Sign up / Sign in / Back to login) — an + * optional muted prompt followed by an outline {@link ChipLink} pill, matching + * the landing's secondary chip CTAs. Centralizes the auth nav affordance so the + * pill chrome is described by props, never restyled per page. + */ +export function AuthNavPrompt({ prompt, href, linkLabel, onNavigate }: AuthNavPromptProps) { + return ( +
      + {prompt && {prompt}} + + {linkLabel} + +
      + ) +} diff --git a/apps/sim/app/(auth)/components/auth-shell.tsx b/apps/sim/app/(auth)/components/auth-shell.tsx new file mode 100644 index 00000000000..91f4d324d6c --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-shell.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react' +import Link from 'next/link' +import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components' + +interface AuthShellProps { + /** Centered content column (the form, status copy, etc.). */ + children: ReactNode + /** Optional element pinned to the bottom of the shell (e.g. the support footer). */ + footer?: ReactNode +} + +/** + * The light auth/status page frame — the single source of truth for the shell + * every auth page and standalone status page wears. + * + * Mirrors the landing chrome: it pins the `light` token layer (so the platform's + * light-mode `var(--*)` tokens resolve regardless of the visitor's theme), uses + * the canvas/`--text-primary` surface, and renders a logo-only header that reuses + * the landing {@link LogoMark} + {@link SimWordmark} at the same nav gutters. The + * single content column is centered and capped for a calm single-form layout. + */ +export function AuthShell({ children, footer }: AuthShellProps) { + return ( +
      +
      + +
      +
      +
      {children}
      +
      + {footer} +
      + ) +} diff --git a/apps/sim/app/(auth)/components/auth-submit-button.tsx b/apps/sim/app/(auth)/components/auth-submit-button.tsx new file mode 100644 index 00000000000..c08cb3249a9 --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-submit-button.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react' +import { Chip, Loader } from '@sim/emcn' +import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' + +interface AuthSubmitButtonProps { + children: ReactNode + /** Label shown beside the spinner while the action is in flight. */ + loadingLabel: string + loading?: boolean + disabled?: boolean + type?: 'submit' | 'button' + onClick?: () => void +} + +/** + * The canonical full-width primary auth action — a `primary`-variant {@link Chip} + * with the shared in-flight spinner. Replaces the legacy dark + * `AUTH_SUBMIT_BTN` class string for every in-scope auth submit (login, signup, + * verify, reset), so the primary CTA chrome lives in exactly one place. + */ +export function AuthSubmitButton({ + children, + loadingLabel, + loading = false, + disabled = false, + type = 'submit', + onClick, +}: AuthSubmitButtonProps) { + return ( + + {loading ? ( + + + {loadingLabel} + + ) : ( + children + )} + + ) +} diff --git a/apps/sim/app/(auth)/components/auth-text-link.tsx b/apps/sim/app/(auth)/components/auth-text-link.tsx new file mode 100644 index 00000000000..71a7d0f995d --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-text-link.tsx @@ -0,0 +1,58 @@ +'use client' + +import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' +import Link from 'next/link' + +const AUTH_TEXT_LINK_CLASS = + 'text-[var(--text-secondary)] underline-offset-4 transition-colors hover:text-[var(--text-primary)] hover:underline disabled:cursor-not-allowed disabled:opacity-50' + +interface AuthTextLinkProps { + children: ReactNode + /** Renders a navigation link when set; otherwise renders an action button. */ + href?: string + onClick?: () => void + /** Opens the link in a new tab with safe `rel` (e.g. Terms/Privacy). */ + external?: boolean + disabled?: boolean + className?: string +} + +/** + * The canonical inline text affordance for the auth pages — forgot-password, + * resend, and the legal links. Renders a {@link Link} when `href` is set and a + * ` + ) +} diff --git a/apps/sim/app/(auth)/components/constants.ts b/apps/sim/app/(auth)/components/constants.ts new file mode 100644 index 00000000000..130b84711b6 --- /dev/null +++ b/apps/sim/app/(auth)/components/constants.ts @@ -0,0 +1,17 @@ +/** + * Auth and invite surfaces use a slightly taller control than the 30px chip + * default, matching the landing `HeroCta` field family (the landing's own + * auth-adjacent CTA renders taller fields than in-app chips). Applied as the + * single source of truth for every auth field and button height so the inputs, + * submit, social, SSO, and invite action buttons stay on one line. + */ +export const AUTH_CONTROL_HEIGHT = 'h-9' + +/** + * Shared layout for full-width auth/invite chip buttons (submit, social, SSO, + * invite actions). `[&>span]:flex-none` collapses the chip's stretching label + * span — which carries `flex-1` — so the icon + label cluster truly centers + * under `justify-center` (the landing `HeroCta` idiom). Height-only inputs use + * {@link AUTH_CONTROL_HEIGHT}; buttons compose this on top of it. + */ +export const AUTH_BUTTON_CLASS = `${AUTH_CONTROL_HEIGHT} w-full justify-center [&>span]:flex-none` diff --git a/apps/sim/app/(auth)/components/index.ts b/apps/sim/app/(auth)/components/index.ts new file mode 100644 index 00000000000..f559ddd54b8 --- /dev/null +++ b/apps/sim/app/(auth)/components/index.ts @@ -0,0 +1,14 @@ +export { AuthDivider } from './auth-divider' +export { AuthField } from './auth-field' +export { AuthFormMessage } from './auth-form-message' +export { AuthHeader } from './auth-header' +export { AuthInput } from './auth-input' +export { AuthLegalFooter } from './auth-legal-footer' +export { AuthNavPrompt } from './auth-nav-prompt' +export { AuthShell } from './auth-shell' +export { AuthSubmitButton } from './auth-submit-button' +export { AuthTextLink } from './auth-text-link' +export { PasswordInput } from './password-input' +export { SocialLoginButtons } from './social-login-buttons' +export { SSOLoginButton } from './sso-login-button' +export { SupportFooter } from './support-footer' diff --git a/apps/sim/app/(auth)/components/password-input.tsx b/apps/sim/app/(auth)/components/password-input.tsx new file mode 100644 index 00000000000..d602b24e25e --- /dev/null +++ b/apps/sim/app/(auth)/components/password-input.tsx @@ -0,0 +1,37 @@ +'use client' + +import { useState } from 'react' +import { ChipInput, type ChipInputProps, cn } from '@sim/emcn' +import { Eye, EyeOff } from 'lucide-react' +import { AUTH_CONTROL_HEIGHT } from '@/app/(auth)/components/constants' + +type PasswordInputProps = Omit + +/** + * A {@link ChipInput} that owns the password reveal toggle — the eye button is + * driven through the canonical `endAdornment` slot and the field's invalid state + * through the `error` prop, so no consumer hand-rolls the relative wrapper + + * absolutely positioned button the auth forms previously duplicated four times. + */ +export function PasswordInput({ error, className, ...props }: PasswordInputProps) { + const [visible, setVisible] = useState(false) + + return ( + setVisible((v) => !v)} + aria-label={visible ? 'Hide password' : 'Show password'} + className='flex shrink-0 text-[var(--text-icon)] transition-colors hover:text-[var(--text-primary)]' + > + {visible ? : } + + } + /> + ) +} diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx index feaf4889940..f315e52da97 100644 --- a/apps/sim/app/(auth)/components/social-login-buttons.tsx +++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx @@ -1,9 +1,14 @@ 'use client' import { type ReactNode, useState } from 'react' -import { Button } from '@sim/emcn' +import { Chip, cn } from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons' import { client } from '@/lib/auth/auth-client' +import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' + +const logger = createLogger('SocialLoginButtons') interface SocialLoginButtonsProps { githubAvailable: boolean @@ -32,18 +37,8 @@ export function SocialLoginButtons({ setIsGithubLoading(true) try { await client.signIn.social({ provider: 'github', callbackURL }) - } catch (err: any) { - let errorMessage = 'Failed to sign in with GitHub' - - if (err.message?.includes('account exists')) { - errorMessage = 'An account with this email already exists. Please sign in instead.' - } else if (err.message?.includes('cancelled')) { - errorMessage = 'GitHub sign in was cancelled. Please try again.' - } else if (err.message?.includes('network')) { - errorMessage = 'Network error. Please check your connection and try again.' - } else if (err.message?.includes('rate limit')) { - errorMessage = 'Too many attempts. Please try again later.' - } + } catch (err) { + logger.error('GitHub sign-in failed', { error: getErrorMessage(err) }) } finally { setIsGithubLoading(false) } @@ -55,18 +50,8 @@ export function SocialLoginButtons({ setIsGoogleLoading(true) try { await client.signIn.social({ provider: 'google', callbackURL }) - } catch (err: any) { - let errorMessage = 'Failed to sign in with Google' - - if (err.message?.includes('account exists')) { - errorMessage = 'An account with this email already exists. Please sign in instead.' - } else if (err.message?.includes('cancelled')) { - errorMessage = 'Google sign in was cancelled. Please try again.' - } else if (err.message?.includes('network')) { - errorMessage = 'Network error. Please check your connection and try again.' - } else if (err.message?.includes('rate limit')) { - errorMessage = 'Too many attempts. Please try again later.' - } + } catch (err) { + logger.error('Google sign-in failed', { error: getErrorMessage(err) }) } finally { setIsGoogleLoading(false) } @@ -78,57 +63,50 @@ export function SocialLoginButtons({ setIsMicrosoftLoading(true) try { await client.signIn.social({ provider: 'microsoft', callbackURL }) - } catch (err: any) { - let errorMessage = 'Failed to sign in with Microsoft' - - if (err.message?.includes('account exists')) { - errorMessage = 'An account with this email already exists. Please sign in instead.' - } else if (err.message?.includes('cancelled')) { - errorMessage = 'Microsoft sign in was cancelled. Please try again.' - } else if (err.message?.includes('network')) { - errorMessage = 'Network error. Please check your connection and try again.' - } else if (err.message?.includes('rate limit')) { - errorMessage = 'Too many attempts. Please try again later.' - } + } catch (err) { + logger.error('Microsoft sign-in failed', { error: getErrorMessage(err) }) } finally { setIsMicrosoftLoading(false) } } const githubButton = ( - + {isGithubLoading ? 'Connecting…' : 'GitHub'} + ) const googleButton = ( - + {isGoogleLoading ? 'Connecting…' : 'Google'} + ) const microsoftButton = ( - + {isMicrosoftLoading ? 'Connecting…' : 'Microsoft'} + ) const hasAnyOAuthProvider = githubAvailable || googleAvailable || microsoftAvailable @@ -138,7 +116,7 @@ export function SocialLoginButtons({ } return ( -
      +
      {googleAvailable && googleButton} {microsoftAvailable && microsoftButton} {githubAvailable && githubButton} diff --git a/apps/sim/app/(auth)/components/sso-login-button.tsx b/apps/sim/app/(auth)/components/sso-login-button.tsx index 851fd1bf993..ddca42526ca 100644 --- a/apps/sim/app/(auth)/components/sso-login-button.tsx +++ b/apps/sim/app/(auth)/components/sso-login-button.tsx @@ -1,8 +1,8 @@ 'use client' -import { Button, cn } from '@sim/emcn' +import { Chip, cn } from '@sim/emcn' import { useRouter } from 'next/navigation' import { getEnv, isTruthy } from '@/lib/core/config/env' -import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' +import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' interface SSOLoginButtonProps { callbackURL?: string @@ -26,18 +26,19 @@ export function SSOLoginButton({ router.push(ssoUrl) } - const outlineBtnClasses = cn( - 'w-full rounded-sm border-[var(--landing-border-strong)] py-1.5 text-sm' - ) - return ( - + ) } diff --git a/apps/sim/app/(auth)/components/status-page-layout.tsx b/apps/sim/app/(auth)/components/status-page-layout.tsx deleted file mode 100644 index b26def8d71a..00000000000 --- a/apps/sim/app/(auth)/components/status-page-layout.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { ReactNode } from 'react' -import AuthBackground from '@/app/(auth)/components/auth-background' -import Navbar from '@/app/(landing)/components/navbar/navbar' -import { SupportFooter } from './support-footer' - -export interface StatusPageLayoutProps { - title: string - description: string | ReactNode - children?: ReactNode - showSupportFooter?: boolean -} - -export function StatusPageLayout({ - title, - description, - children, - showSupportFooter = true, -}: StatusPageLayoutProps) { - return ( - -
      -
      - -
      -
      -
      -
      -
      -

      - {title} -

      -

      - {description} -

      -
      - {children &&
      {children}
      } -
      -
      -
      - {showSupportFooter && } -
      -
      - ) -} diff --git a/apps/sim/app/(auth)/components/support-footer.tsx b/apps/sim/app/(auth)/components/support-footer.tsx index 6dad3bd6ac1..29ea677ea1b 100644 --- a/apps/sim/app/(auth)/components/support-footer.tsx +++ b/apps/sim/app/(auth)/components/support-footer.tsx @@ -1,5 +1,6 @@ 'use client' +import { cn } from '@sim/emcn' import { useBrandConfig } from '@/ee/whitelabeling' export interface SupportFooterProps { @@ -11,12 +12,15 @@ export function SupportFooter({ position = 'fixed' }: SupportFooterProps) { return (
      Need help?{' '} Contact support diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 8f102308bc0..d0f1fa59a29 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -8,15 +8,9 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, - cn, - Input, - Label, - Loader, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { Eye, EyeOff } from 'lucide-react' -import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { requestJson } from '@/lib/api/client/request' import { forgetPasswordContract } from '@/lib/api/contracts' @@ -26,9 +20,20 @@ import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { captureClientEvent } from '@/lib/posthog/client' -import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' -import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' -import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' +import { + AuthDivider, + AuthField, + AuthFormMessage, + AuthHeader, + AuthInput, + AuthLegalFooter, + AuthNavPrompt, + AuthSubmitButton, + AuthTextLink, + PasswordInput, + SocialLoginButtons, + SSOLoginButton, +} from '@/app/(auth)/components' const logger = createLogger('LoginForm') @@ -89,11 +94,9 @@ export default function LoginPage({ const router = useRouter() const searchParams = useSearchParams() const [isLoading, setIsLoading] = useState(false) - const [showPassword, setShowPassword] = useState(false) const [password, setPassword] = useState('') const [passwordErrors, setPasswordErrors] = useState([]) const [showValidationError, setShowValidationError] = useState(false) - const [formError, setFormError] = useState(null) const callbackUrlParam = searchParams?.get('callbackUrl') const isValidCallbackUrl = callbackUrlParam ? validateCallbackUrl(callbackUrlParam) : false const invalidCallbackRef = useRef(false) @@ -175,7 +178,6 @@ export default function LoginPage({ const safeCallbackUrl = callbackUrl let errorHandled = false - setFormError(null) const result = await client.signIn.email( { email, @@ -343,150 +345,81 @@ export default function LoginPage({ const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO) const showDivider = (emailEnabled || showTopSSO) && showBottomSection + const emailFieldErrors = showEmailValidationError && emailErrors.length > 0 ? emailErrors : [] + const passwordFieldErrors = showValidationError && passwordErrors.length > 0 ? passwordErrors : [] + const canSubmit = email.trim().length > 0 && password.length > 0 + return ( <> -
      -

      - Sign in -

      -

      - Enter your details -

      -
      - - {/* SSO Login Button (primary top-only when it is the only method) */} - {showTopSSO && ( -
      - -
      - )} - - {/* Email/Password Form - show unless explicitly disabled */} - {!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && ( -
      -
      -
      -
      - -
      - 0 && - 'border-[var(--text-error)] focus:border-[var(--text-error)]' - )} - /> - {showEmailValidationError && emailErrors.length > 0 && ( -
      - {emailErrors.map((error) => ( -

      {error}

      - ))} -
      - )} -
      -
      -
      - - -
      -
      - + + + {showTopSSO && } + + {emailEnabled && ( + +
      + + 0} + /> + + setForgotPasswordOpen(true)} + className='text-caption' + > + Forgot password? + + } + > + 0 && - 'border-[var(--text-error)] focus:border-[var(--text-error)]' - )} + error={passwordFieldErrors.length > 0} /> - -
      - {showValidationError && passwordErrors.length > 0 && ( -
      - {passwordErrors.map((error) => ( -

      {error}

      - ))} -
      - )} +
      -
      - {resetSuccessMessage && ( -
      -

      {resetSuccessMessage}

      -
      - )} + {resetSuccessMessage && ( + +

      {resetSuccessMessage}

      +
      + )} - {formError && ( -
      -

      {formError}

      -
      - )} + + Sign in + + + )} - - - )} - - {/* Divider - show when we have multiple auth methods */} - {showDivider && ( -
      -
      -
      -
      -
      - - Or continue with - -
      -
      - )} - - {showBottomSection && ( -
      + {showDivider && } + + {showBottomSection && ( @@ -494,41 +427,17 @@ export default function LoginPage({ )} -
      - )} - - {/* Only show signup link if email/password signup is enabled */} - {!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && ( -
      - Don't have an account? - - Sign up - -
      - )} - -
      - By signing in, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - + linkLabel='Sign up' + /> + )} + +
      -
      -

      - Reset your password -

      -

      - Enter a new password for your account -

      -
      +
      + -
      - -
      + -
      - - Back to login - -
      - + +
      ) } diff --git a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx index 301a804d849..f2825665766 100644 --- a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx +++ b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx @@ -1,81 +1,13 @@ 'use client' import { useState } from 'react' -import { cn, Input, Label, Loader } from '@sim/emcn' -import { Eye, EyeOff } from 'lucide-react' -import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' - -interface RequestResetFormProps { - email: string - onEmailChange: (email: string) => void - onSubmit: (email: string) => Promise - isSubmitting: boolean - statusType: 'success' | 'error' | null - statusMessage: string - className?: string -} - -export function RequestResetForm({ - email, - onEmailChange, - onSubmit, - isSubmitting, - statusType, - statusMessage, - className, -}: RequestResetFormProps) { - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - onSubmit(email) - } - - return ( -
      -
      -
      -
      - -
      - onEmailChange(e.target.value)} - placeholder='Enter your email' - type='email' - disabled={isSubmitting} - required - /> -

      - We'll send a password reset link to this email address. -

      -
      - - {/* Status message display */} - {statusType && statusMessage && ( -
      -

      {statusMessage}

      -
      - )} -
      - - -
      - ) -} +import { cn } from '@sim/emcn' +import { + AuthField, + AuthFormMessage, + AuthSubmitButton, + PasswordInput, +} from '@/app/(auth)/components' interface SetNewPasswordFormProps { token: string | null @@ -97,8 +29,6 @@ export function SetNewPasswordForm({ const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [validationMessages, setValidationMessages] = useState([]) - const [showPassword, setShowPassword] = useState(false) - const [showConfirmPassword, setShowConfirmPassword] = useState(false) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -142,102 +72,62 @@ export function SetNewPasswordForm({ onSubmit(password) } + const hasValidationErrors = validationMessages.length > 0 + return ( -
      -
      -
      -
      - -
      -
      - setPassword(e.target.value)} - required - placeholder='Enter new password' - className={cn( - 'pr-10', - validationMessages.length > 0 && 'border-red-500 focus:border-red-500' - )} - /> - -
      -
      -
      -
      - -
      -
      - setConfirmPassword(e.target.value)} - required - placeholder='Confirm new password' - className={cn( - 'pr-10', - validationMessages.length > 0 && 'border-red-500 focus:border-red-500' - )} - /> - -
      -
      - - {validationMessages.length > 0 && ( -
      + +
      + + setPassword(e.target.value)} + required + placeholder='Enter new password' + error={hasValidationErrors} + /> + + + setConfirmPassword(e.target.value)} + required + placeholder='Confirm new password' + error={hasValidationErrors} + /> + + + {hasValidationErrors && ( + {validationMessages.map((error) => (

      {error}

      ))} -
      + )} {statusType && statusMessage && ( -
      +

      {statusMessage}

      -
      + )}
      - + + Reset Password + ) } diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 62be1b98958..5553cfa6fe9 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -2,10 +2,7 @@ import { Suspense, useEffect, useMemo, useRef, useState } from 'react' import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' -import { cn, Input, Label, Loader } from '@sim/emcn' import { createLogger } from '@sim/logger' -import { Eye, EyeOff } from 'lucide-react' -import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { client, useSession } from '@/lib/auth/auth-client' @@ -13,9 +10,19 @@ import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env' import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { captureClientEvent, captureEvent } from '@/lib/posthog/client' -import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' -import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' -import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' +import { + AuthDivider, + AuthField, + AuthFormMessage, + AuthHeader, + AuthInput, + AuthLegalFooter, + AuthNavPrompt, + AuthSubmitButton, + PasswordInput, + SocialLoginButtons, + SSOLoginButton, +} from '@/app/(auth)/components' const logger = createLogger('SignupForm') @@ -95,7 +102,6 @@ function SignupFormContent({ useEffect(() => { captureClientEvent('signup_page_viewed', {}) }, []) - const [showPassword, setShowPassword] = useState(false) const [password, setPassword] = useState('') const [passwordErrors, setPasswordErrors] = useState([]) const [showValidationError, setShowValidationError] = useState(false) @@ -361,163 +367,66 @@ function SignupFormContent({ const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO) const showDivider = (emailEnabled || hasOnlySSO) && showBottomSection + const nameFieldErrors = showNameValidationError && nameErrors.length > 0 ? nameErrors : [] + const emailHasError = Boolean(emailError) || (showEmailValidationError && emailErrors.length > 0) + const emailFieldErrors = + showEmailValidationError && emailErrors.length > 0 + ? emailErrors + : emailError && !showEmailValidationError + ? [emailError] + : [] + const passwordFieldErrors = showValidationError && passwordErrors.length > 0 ? passwordErrors : [] + const canSubmit = name.trim().length > 0 && email.trim().length > 0 && password.length > 0 + return ( - <> -
      -

      - Create an account -

      -

      - Create an account or log in -

      -
      - - {hasOnlySSO && ( -
      - -
      - )} +
      + + + {hasOnlySSO && } {emailEnabled && ( -
      -
      -
      -
      - -
      -
      - 0 && - 'border-red-500 focus:border-red-500' - )} - /> -
      0 - ? 'grid-rows-[1fr]' - : 'grid-rows-[0fr]' - )} - aria-live={showNameValidationError && nameErrors.length > 0 ? 'polite' : 'off'} - > -
      -
      - {nameErrors.map((error) => ( -

      {error}

      - ))} -
      -
      -
      -
      -
      -
      -
      - -
      -
      - 0)) && - 'border-red-500 focus:border-red-500' - )} - /> -
      0) || - (emailError && !showEmailValidationError) - ? 'grid-rows-[1fr]' - : 'grid-rows-[0fr]' - )} - aria-live={ - (showEmailValidationError && emailErrors.length > 0) || - (emailError && !showEmailValidationError) - ? 'polite' - : 'off' - } - > -
      -
      - {showEmailValidationError && emailErrors.length > 0 ? ( - emailErrors.map((error) =>

      {error}

      ) - ) : emailError && !showEmailValidationError ? ( -

      {emailError}

      - ) : null} -
      -
      -
      -
      -
      -
      -
      - -
      -
      -
      - 0 && - 'border-red-500 focus:border-red-500' - )} - /> - -
      -
      0 - ? 'grid-rows-[1fr]' - : 'grid-rows-[0fr]' - )} - aria-live={showValidationError && passwordErrors.length > 0 ? 'polite' : 'off'} - > -
      -
      - {passwordErrors.map((error) => ( -

      {error}

      - ))} -
      -
      -
      -
      -
      + +
      + + 0} + /> + + + + + + 0} + /> +
      {turnstileSiteKey && ( @@ -529,84 +438,45 @@ function SignupFormContent({ )} {formError && ( -
      +

      {formError}

      -
      + )} - + + Create account + )} - {showDivider && ( -
      -
      -
      -
      -
      - - Or continue with - -
      -
      - )} + {showDivider && } {showBottomSection && ( -
      - - {ssoEnabled && !hasOnlySSO && ( - - )} - -
      + + {ssoEnabled && !hasOnlySSO && ( + + )} + )} -
      - Already have an account? - - Sign in - -
      - -
      - By creating an account, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - -
      - + + + +
      ) } diff --git a/apps/sim/app/(auth)/verify/verify-content.tsx b/apps/sim/app/(auth)/verify/verify-content.tsx index 11e68bbf32a..aad098f2770 100644 --- a/apps/sim/app/(auth)/verify/verify-content.tsx +++ b/apps/sim/app/(auth)/verify/verify-content.tsx @@ -1,9 +1,14 @@ 'use client' import { Suspense, useEffect, useState } from 'react' -import { cn, InputOTP, InputOTPGroup, InputOTPSlot, Loader } from '@sim/emcn' -import { useRouter } from 'next/navigation' -import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' +import { cn, InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn' +import { + AuthFormMessage, + AuthHeader, + AuthNavPrompt, + AuthSubmitButton, + AuthTextLink, +} from '@/app/(auth)/components' import { useVerification } from '@/app/(auth)/verify/use-verification' interface VerifyContentProps { @@ -12,6 +17,8 @@ interface VerifyContentProps { isEmailVerificationEnabled: boolean } +const OTP_SLOTS = [0, 1, 2, 3, 4, 5] as const + function VerificationForm({ hasEmailService, isProduction, @@ -34,8 +41,8 @@ function VerificationForm({ } = useVerification({ hasEmailService, isProduction, isEmailVerificationEnabled }) const isVerified = status === 'verified' + const isLoading = status === 'verifying' || isResending const isInvalidOtp = status === 'error' - const isBusy = status === 'verifying' || isResending const [countdown, setCountdown] = useState(0) const [isResendDisabled, setIsResendDisabled] = useState(false) @@ -50,8 +57,6 @@ function VerificationForm({ } }, [countdown, isResendDisabled]) - const router = useRouter() - const handleResend = () => { resendCode() setIsResendDisabled(true) @@ -59,13 +64,11 @@ function VerificationForm({ } return ( - <> -
      -

      - {isVerified ? 'Email Verified!' : 'Verify Your Email'} -

      -

      - {isVerified +

      + -
      + : 'Error: Email verification is enabled but no email service is configured' + } + /> {!isVerified && isEmailVerificationEnabled && ( -
      -
      -

      +

      +
      +

      Enter the 6-digit code to verify your account. {hasEmailService ? " If you don't see it in your inbox, check your spam folder." : ''}

      - + - - - - - - + {OTP_SLOTS.map((index) => ( + + ))}
      - {/* Error message */} {errorMessage && ( -
      +

      {errorMessage}

      -
      + )}
      - + Verify Email + {hasEmailService && ( -
      -

      - Didn't receive a code?{' '} - {countdown > 0 ? ( - - Resend in{' '} - {countdown}s - - ) : ( - - )} -

      -
      +

      + Didn't receive a code?{' '} + {countdown > 0 ? ( + + Resend in {countdown}s + + ) : ( + + Resend + + )} +

      )} -
      - -
      + { + if (typeof window !== 'undefined') { + sessionStorage.removeItem('verificationEmail') + sessionStorage.removeItem('inviteRedirectUrl') + sessionStorage.removeItem('isInviteFlow') + } + }} + />
      )} - +
      ) } diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx similarity index 98% rename from apps/sim/app/chat/[identifier]/chat.tsx rename to apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx index 891727d5784..6d4a9435a63 100644 --- a/apps/sim/app/chat/[identifier]/chat.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx @@ -14,9 +14,9 @@ import { EmailAuth, PasswordAuth, VoiceInterface, -} from '@/app/chat/components' -import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants' -import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks' +} from '@/app/(interfaces)/chat/components' +import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/(interfaces)/chat/constants' +import { useAudioStreaming, useChatStreaming } from '@/app/(interfaces)/chat/hooks' import SSOAuth from '@/ee/sso/components/sso-auth' import { useDeployedChatConfig } from '@/hooks/queries/chats' import { useGitHubStars } from '@/hooks/queries/github-stars' diff --git a/apps/sim/app/chat/[identifier]/loading.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx similarity index 100% rename from apps/sim/app/chat/[identifier]/loading.tsx rename to apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx diff --git a/apps/sim/app/chat/[identifier]/office-embed-init.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/office-embed-init.tsx similarity index 100% rename from apps/sim/app/chat/[identifier]/office-embed-init.tsx rename to apps/sim/app/(interfaces)/chat/[identifier]/office-embed-init.tsx diff --git a/apps/sim/app/chat/[identifier]/page.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/page.tsx similarity index 79% rename from apps/sim/app/chat/[identifier]/page.tsx rename to apps/sim/app/(interfaces)/chat/[identifier]/page.tsx index 6c5093e4a3b..5ed89f37fcc 100644 --- a/apps/sim/app/chat/[identifier]/page.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next' -import ChatClient from '@/app/chat/[identifier]/chat' -import { OfficeEmbedInit } from '@/app/chat/[identifier]/office-embed-init' +import ChatClient from '@/app/(interfaces)/chat/[identifier]/chat' +import { OfficeEmbedInit } from '@/app/(interfaces)/chat/[identifier]/office-embed-init' export const metadata: Metadata = { title: 'Chat', diff --git a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx new file mode 100644 index 00000000000..f5cb4767fc8 --- /dev/null +++ b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx @@ -0,0 +1,260 @@ +'use client' + +import { useEffect, useState } from 'react' +import { cn, Input, InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { AuthSubmitButton } from '@/app/(auth)/components' +import { AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes' +import { useChatEmailOtpRequest, useChatEmailOtpVerify } from '@/hooks/queries/chats' + +const logger = createLogger('EmailAuth') + +interface EmailAuthProps { + identifier: string +} + +const validateEmailField = (emailValue: string): string[] => { + const errors: string[] = [] + + if (!emailValue || !emailValue.trim()) { + errors.push('Email is required.') + return errors + } + + const validation = quickValidateEmail(emailValue.trim().toLowerCase()) + if (!validation.isValid) { + errors.push(validation.reason || 'Please enter a valid email address.') + } + + return errors +} + +export default function EmailAuth({ identifier }: EmailAuthProps) { + const [email, setEmail] = useState('') + const [authError, setAuthError] = useState(null) + const [emailErrors, setEmailErrors] = useState([]) + const [showEmailValidationError, setShowEmailValidationError] = useState(false) + + const [showOtpVerification, setShowOtpVerification] = useState(false) + const [otpValue, setOtpValue] = useState('') + const [countdown, setCountdown] = useState(0) + + const requestOtp = useChatEmailOtpRequest(identifier) + const verifyOtp = useChatEmailOtpVerify(identifier) + + useEffect(() => { + if (countdown <= 0) return + const timer = setTimeout(() => setCountdown((c) => c - 1), 1000) + return () => clearTimeout(timer) + }, [countdown]) + + const handleEmailChange = (e: React.ChangeEvent) => { + const newEmail = e.target.value + setEmail(newEmail) + const errors = validateEmailField(newEmail) + setEmailErrors(errors) + setShowEmailValidationError(false) + } + + const handleSendOtp = async () => { + const emailValidationErrors = validateEmailField(email) + setEmailErrors(emailValidationErrors) + setShowEmailValidationError(emailValidationErrors.length > 0) + + if (emailValidationErrors.length > 0) { + return + } + + setAuthError(null) + + try { + await requestOtp.mutateAsync({ email }) + setShowOtpVerification(true) + } catch (error) { + logger.error('Error sending OTP:', error) + setEmailErrors([toError(error).message || 'Failed to send verification code']) + setShowEmailValidationError(true) + } + } + + const handleVerifyOtp = async (otp?: string) => { + const codeToVerify = otp || otpValue + + if (!codeToVerify || codeToVerify.length !== 6) { + return + } + + setAuthError(null) + + try { + await verifyOtp.mutateAsync({ email, otp: codeToVerify }) + } catch (error) { + logger.error('Error verifying OTP:', error) + setAuthError(toError(error).message || 'Invalid verification code') + } + } + + const handleResendOtp = async () => { + setAuthError(null) + setCountdown(30) + + try { + await requestOtp.mutateAsync({ email }) + setOtpValue('') + } catch (error) { + logger.error('Error resending OTP:', error) + setAuthError(toError(error).message || 'Failed to resend verification code') + setCountdown(0) + } + } + + return ( +
      +
      +
      +
      +

      + {showOtpVerification ? 'Verify Your Email' : 'Email Verification'} +

      +

      + {showOtpVerification + ? `A verification code has been sent to ${email}` + : 'This chat requires email verification'} +

      +
      + +
      + {!showOtpVerification ? ( +
      { + e.preventDefault() + handleSendOtp() + }} + className='space-y-6' + > +
      +
      + +
      + 0 && + 'border-[var(--text-error)] focus:border-[var(--text-error)]' + )} + /> + {showEmailValidationError && emailErrors.length > 0 && ( +
      + {emailErrors.map((error) => ( +

      {error}

      + ))} +
      + )} +
      + + + Continue + +
      + ) : ( +
      +

      + Enter the 6-digit code to verify your account. If you don't see it in your inbox, + check your spam folder. +

      + +
      + { + setOtpValue(value) + if (value.length === 6) { + handleVerifyOtp(value) + } + }} + disabled={verifyOtp.isPending} + className={cn('gap-2', authError && 'otp-error')} + > + + {[0, 1, 2, 3, 4, 5].map((index) => ( + + ))} + + +
      + + {authError && ( +
      +

      {authError}

      +
      + )} + + handleVerifyOtp()} + disabled={otpValue.length !== 6} + loading={verifyOtp.isPending} + loadingLabel='Verifying…' + > + Verify Email + + +
      +

      + Didn't receive a code?{' '} + {countdown > 0 ? ( + + Resend in{' '} + {countdown}s + + ) : ( + + )} +

      +
      + +
      + +
      +
      + )} +
      +
      +
      +
      + ) +} diff --git a/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx b/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx new file mode 100644 index 00000000000..0d6a1841e9c --- /dev/null +++ b/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx @@ -0,0 +1,134 @@ +'use client' + +import { useState } from 'react' +import { cn, Input, Label } from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { Eye, EyeOff } from 'lucide-react' +import { AuthSubmitButton } from '@/app/(auth)/components' +import { useChatPasswordAuth } from '@/hooks/queries/chats' + +const logger = createLogger('PasswordAuth') + +interface PasswordAuthProps { + identifier: string +} + +export default function PasswordAuth({ identifier }: PasswordAuthProps) { + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [showValidationError, setShowValidationError] = useState(false) + const [passwordErrors, setPasswordErrors] = useState([]) + const authenticate = useChatPasswordAuth(identifier) + + const handlePasswordChange = (e: React.ChangeEvent) => { + const newPassword = e.target.value + setPassword(newPassword) + setShowValidationError(false) + setPasswordErrors([]) + } + + const handleAuthenticate = async () => { + if (!password.trim()) { + setPasswordErrors(['Password is required']) + setShowValidationError(true) + return + } + + try { + await authenticate.mutateAsync({ password }) + setPassword('') + } catch (error) { + logger.error('Authentication error:', error) + setPasswordErrors([toError(error).message || 'Invalid password. Please try again.']) + setShowValidationError(true) + } + } + + return ( +
      +
      +
      +
      +

      + Password Required +

      +

      + This chat is password-protected +

      +
      + +
      { + e.preventDefault() + handleAuthenticate() + }} + className='mt-8 w-full max-w-[410px] space-y-6' + > +
      +
      + +
      +
      +
      + 0 && + 'border-[var(--text-error)] focus:border-[var(--text-error)]' + )} + /> + +
      +
      0 + ? 'grid-rows-[1fr]' + : 'grid-rows-[0fr]' + )} + aria-live='polite' + > +
      +
      + {passwordErrors.map((error) => ( +

      {error}

      + ))} +
      +
      +
      +
      +
      + + + Continue + +
      +
      +
      +
      + ) +} diff --git a/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx b/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx new file mode 100644 index 00000000000..23cbd1789e9 --- /dev/null +++ b/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx @@ -0,0 +1,28 @@ +'use client' + +import { useRouter } from 'next/navigation' + +interface ChatErrorStateProps { + error: string +} + +export function ChatErrorState({ error }: ChatErrorStateProps) { + const router = useRouter() + + return ( +
      +
      +

      + Chat Unavailable +

      +

      {error}

      + +
      +
      + ) +} diff --git a/apps/sim/app/chat/components/header/header.tsx b/apps/sim/app/(interfaces)/chat/components/header/header.tsx similarity index 100% rename from apps/sim/app/chat/components/header/header.tsx rename to apps/sim/app/(interfaces)/chat/components/header/header.tsx diff --git a/apps/sim/app/chat/components/index.ts b/apps/sim/app/(interfaces)/chat/components/index.ts similarity index 100% rename from apps/sim/app/chat/components/index.ts rename to apps/sim/app/(interfaces)/chat/components/index.ts diff --git a/apps/sim/app/chat/components/input/input.tsx b/apps/sim/app/(interfaces)/chat/components/input/input.tsx similarity index 99% rename from apps/sim/app/chat/components/input/input.tsx rename to apps/sim/app/(interfaces)/chat/components/input/input.tsx index 72aa99fb9d0..a12e575ce46 100644 --- a/apps/sim/app/chat/components/input/input.tsx +++ b/apps/sim/app/(interfaces)/chat/components/input/input.tsx @@ -7,7 +7,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { ArrowUp, Mic, Paperclip, X } from 'lucide-react' import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' -import { VoiceInput } from '@/app/chat/components/input/voice-input' +import { VoiceInput } from '@/app/(interfaces)/chat/components/input/voice-input' const logger = createLogger('ChatInput') diff --git a/apps/sim/app/chat/components/input/voice-input.tsx b/apps/sim/app/(interfaces)/chat/components/input/voice-input.tsx similarity index 100% rename from apps/sim/app/chat/components/input/voice-input.tsx rename to apps/sim/app/(interfaces)/chat/components/input/voice-input.tsx diff --git a/apps/sim/app/chat/components/loading-state/loading-state.tsx b/apps/sim/app/(interfaces)/chat/components/loading-state/loading-state.tsx similarity index 100% rename from apps/sim/app/chat/components/loading-state/loading-state.tsx rename to apps/sim/app/(interfaces)/chat/components/loading-state/loading-state.tsx diff --git a/apps/sim/app/chat/components/message-container/message-container.tsx b/apps/sim/app/(interfaces)/chat/components/message-container/message-container.tsx similarity index 96% rename from apps/sim/app/chat/components/message-container/message-container.tsx rename to apps/sim/app/(interfaces)/chat/components/message-container/message-container.tsx index d85be38058e..7f896b11c7d 100644 --- a/apps/sim/app/chat/components/message-container/message-container.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message-container/message-container.tsx @@ -3,7 +3,10 @@ import { memo, type RefObject } from 'react' import { ArrowDown } from 'lucide-react' import { Button } from '@/components/ui/button' -import { type ChatMessage, ClientChatMessage } from '@/app/chat/components/message/message' +import { + type ChatMessage, + ClientChatMessage, +} from '@/app/(interfaces)/chat/components/message/message' interface ChatMessageContainerProps { messages: ChatMessage[] diff --git a/apps/sim/app/chat/components/message/components/file-download.test.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx similarity index 94% rename from apps/sim/app/chat/components/message/components/file-download.test.tsx rename to apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx index 423cdc78731..01bff16b95b 100644 --- a/apps/sim/app/chat/components/message/components/file-download.test.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx @@ -23,7 +23,7 @@ vi.mock('@/lib/core/config/env-flags', () => ({ isProd: false, })) -import { isSafeHttpUrl } from '@/app/chat/components/message/components/file-download' +import { isSafeHttpUrl } from '@/app/(interfaces)/chat/components/message/components/file-download' describe('isSafeHttpUrl', () => { it('allows absolute http(s) URLs', () => { diff --git a/apps/sim/app/chat/components/message/components/file-download.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx similarity index 98% rename from apps/sim/app/chat/components/message/components/file-download.tsx rename to apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx index 8dfb200b61b..42825f74ef2 100644 --- a/apps/sim/app/chat/components/message/components/file-download.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx @@ -7,7 +7,7 @@ import { sleep } from '@sim/utils/helpers' import { Music } from 'lucide-react' import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons' import { getBrowserOrigin } from '@/lib/core/utils/urls' -import type { ChatFile } from '@/app/chat/components/message/message' +import type { ChatFile } from '@/app/(interfaces)/chat/components/message/message' const logger = createLogger('ChatFileDownload') diff --git a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/markdown-renderer.tsx similarity index 100% rename from apps/sim/app/chat/components/message/components/markdown-renderer.tsx rename to apps/sim/app/(interfaces)/chat/components/message/components/markdown-renderer.tsx diff --git a/apps/sim/app/chat/components/message/message.test.tsx b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx similarity index 80% rename from apps/sim/app/chat/components/message/message.test.tsx rename to apps/sim/app/(interfaces)/chat/components/message/message.test.tsx index a6428d5188c..a48517a75f1 100644 --- a/apps/sim/app/chat/components/message/message.test.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx @@ -8,16 +8,16 @@ vi.mock('@sim/emcn', () => ({ Tooltip: {}, })) -vi.mock('@/app/chat/components/message/components/file-download', () => ({ +vi.mock('@/app/(interfaces)/chat/components/message/components/file-download', () => ({ ChatFileDownload: () => null, ChatFileDownloadAll: () => null, })) -vi.mock('@/app/chat/components/message/components/markdown-renderer', () => ({ +vi.mock('@/app/(interfaces)/chat/components/message/components/markdown-renderer', () => ({ default: () => null, })) -import { escapeHtml } from '@/app/chat/components/message/message' +import { escapeHtml } from '@/app/(interfaces)/chat/components/message/message' describe('escapeHtml', () => { it('escapes all five HTML-significant characters', () => { diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/(interfaces)/chat/components/message/message.tsx similarity index 98% rename from apps/sim/app/chat/components/message/message.tsx rename to apps/sim/app/(interfaces)/chat/components/message/message.tsx index 12181e0f300..d57775c1adf 100644 --- a/apps/sim/app/chat/components/message/message.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/message.tsx @@ -6,8 +6,8 @@ import { Check, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-re import { ChatFileDownload, ChatFileDownloadAll, -} from '@/app/chat/components/message/components/file-download' -import MarkdownRenderer from '@/app/chat/components/message/components/markdown-renderer' +} from '@/app/(interfaces)/chat/components/message/components/file-download' +import MarkdownRenderer from '@/app/(interfaces)/chat/components/message/components/markdown-renderer' export interface ChatAttachment { id: string diff --git a/apps/sim/app/chat/components/voice-interface/components/particles.tsx b/apps/sim/app/(interfaces)/chat/components/voice-interface/components/particles.tsx similarity index 100% rename from apps/sim/app/chat/components/voice-interface/components/particles.tsx rename to apps/sim/app/(interfaces)/chat/components/voice-interface/components/particles.tsx diff --git a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx b/apps/sim/app/(interfaces)/chat/components/voice-interface/voice-interface.tsx similarity index 99% rename from apps/sim/app/chat/components/voice-interface/voice-interface.tsx rename to apps/sim/app/(interfaces)/chat/components/voice-interface/voice-interface.tsx index 7a7f8ec70f1..00fb29d5e62 100644 --- a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx +++ b/apps/sim/app/(interfaces)/chat/components/voice-interface/voice-interface.tsx @@ -18,7 +18,7 @@ import { const ParticlesVisualization = dynamic( () => - import('@/app/chat/components/voice-interface/components/particles').then( + import('@/app/(interfaces)/chat/components/voice-interface/components/particles').then( (mod) => mod.ParticlesVisualization ), { ssr: false } diff --git a/apps/sim/app/chat/constants.ts b/apps/sim/app/(interfaces)/chat/constants.ts similarity index 100% rename from apps/sim/app/chat/constants.ts rename to apps/sim/app/(interfaces)/chat/constants.ts diff --git a/apps/sim/app/chat/hooks/index.ts b/apps/sim/app/(interfaces)/chat/hooks/index.ts similarity index 100% rename from apps/sim/app/chat/hooks/index.ts rename to apps/sim/app/(interfaces)/chat/hooks/index.ts diff --git a/apps/sim/app/chat/hooks/use-audio-streaming.ts b/apps/sim/app/(interfaces)/chat/hooks/use-audio-streaming.ts similarity index 100% rename from apps/sim/app/chat/hooks/use-audio-streaming.ts rename to apps/sim/app/(interfaces)/chat/hooks/use-audio-streaming.ts diff --git a/apps/sim/app/chat/hooks/use-chat-streaming.ts b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts similarity index 98% rename from apps/sim/app/chat/hooks/use-chat-streaming.ts rename to apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts index dd315dafe73..be4b0b2e1ae 100644 --- a/apps/sim/app/chat/hooks/use-chat-streaming.ts +++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts @@ -5,8 +5,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { readSSEEvents } from '@/lib/core/utils/sse' import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' -import type { ChatFile, ChatMessage } from '@/app/chat/components/message/message' -import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants' +import type { ChatFile, ChatMessage } from '@/app/(interfaces)/chat/components/message/message' +import { CHAT_ERROR_MESSAGES } from '@/app/(interfaces)/chat/constants' const logger = createLogger('UseChatStreaming') diff --git a/apps/sim/app/(interfaces)/components/index.ts b/apps/sim/app/(interfaces)/components/index.ts new file mode 100644 index 00000000000..93ef69c21cd --- /dev/null +++ b/apps/sim/app/(interfaces)/components/index.ts @@ -0,0 +1 @@ +export { InterfacesShell } from './interfaces-shell' diff --git a/apps/sim/app/(interfaces)/components/interfaces-shell/index.ts b/apps/sim/app/(interfaces)/components/interfaces-shell/index.ts new file mode 100644 index 00000000000..93ef69c21cd --- /dev/null +++ b/apps/sim/app/(interfaces)/components/interfaces-shell/index.ts @@ -0,0 +1 @@ +export { InterfacesShell } from './interfaces-shell' diff --git a/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx b/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx new file mode 100644 index 00000000000..7f41bd212d7 --- /dev/null +++ b/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react' +import { SupportFooter } from '@/app/(auth)/components' +import { LogoShell } from '@/app/(landing)/components' + +/** + * Chrome for the `(interfaces)` route group (chat + resume) — the lightweight, + * logo-only frame their entry/gate screens wear (chat email / password auth, the + * embedded SSO gate, the "chat unavailable" message, and the resume gate). + * + * It is the shared {@link LogoShell} (light, logo-only header) plus a + * {@link SupportFooter}. Content is full-width — gate forms center themselves; + * the live chat UI renders a `fixed inset-0` overlay that covers this frame, and + * voice mode is full-screen — so the frame is only ever visible on the + * gate/message states, giving chat and resume the same chrome as the auth pages. + */ +interface InterfacesShellProps { + children: ReactNode +} + +export function InterfacesShell({ children }: InterfacesShellProps) { + return }>{children} +} diff --git a/apps/sim/app/(interfaces)/layout.tsx b/apps/sim/app/(interfaces)/layout.tsx new file mode 100644 index 00000000000..91516d93702 --- /dev/null +++ b/apps/sim/app/(interfaces)/layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' +import { InterfacesShell } from '@/app/(interfaces)/components' + +/** + * Route-group layout for runtime interfaces — chat (`/chat/:identifier`) and + * resume (`/resume/...`). It renders the shared {@link InterfacesShell} (light, + * logo-only chrome) around every interface page, so their entry/gate screens get + * the same frame as the auth sign-in pages. Immersive states (the live chat + * overlay, voice mode) render full-screen on top of this frame. + */ +export default function InterfacesLayout({ children }: { children: ReactNode }) { + return {children} +} diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/[contextId]/page.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/[contextId]/page.tsx similarity index 100% rename from apps/sim/app/resume/[workflowId]/[executionId]/[contextId]/page.tsx rename to apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/[contextId]/page.tsx diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/loading.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/loading.tsx similarity index 100% rename from apps/sim/app/resume/[workflowId]/[executionId]/loading.tsx rename to apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/loading.tsx diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/page.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/page.tsx similarity index 91% rename from apps/sim/app/resume/[workflowId]/[executionId]/page.tsx rename to apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/page.tsx index 67114faec43..7a965893e1f 100644 --- a/apps/sim/app/resume/[workflowId]/[executionId]/page.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' -import ResumeExecutionPage from '@/app/resume/[workflowId]/[executionId]/resume-page-client' +import ResumeExecutionPage from '@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client' export const metadata: Metadata = { title: 'Resume Execution', diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx similarity index 99% rename from apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx rename to apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index b8aa82792f8..6b84e95c67d 100644 --- a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -26,7 +26,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import Navbar from '@/app/(landing)/components/navbar/navbar' +import { Navbar } from '@/app/(landing)/components/navbar/navbar' import { useBrandConfig } from '@/ee/whitelabeling' import { type PauseContextDetail, diff --git a/apps/sim/app/(landing)/CLAUDE.md b/apps/sim/app/(landing)/CLAUDE.md new file mode 100644 index 00000000000..992e2172448 --- /dev/null +++ b/apps/sim/app/(landing)/CLAUDE.md @@ -0,0 +1,101 @@ +# Landing Page - Build & Optimization Instructions + +This route group owns `/` and the entire public marketing surface - the home page, platform/solutions pages, pricing, legal, and the marketing subroutes (`/blog`, `/models`, `/integrations`, `/demo`, `/partners`, `/changelog`). Read this file in full before adding or changing anything here. Positioning and language rules live in `.claude/rules/constitution.md`; SEO/GEO rules in `.claude/rules/landing-seo-geo.md`. Both apply to every file in this directory. + +## What this is + +- `app/(landing)/` - the marketing site. A shared `layout.tsx` renders the chrome once (the `LandingShell`: light tokens, navbar with server-side GitHub stars, footer, site-wide JSON-LD); each page supplies only its `
      ` content. +- The legacy `app/(home)/` group (old dark landing + `--landing-*` tokens) has been **deleted** - its marketing pages were migrated here and its chrome retired. Do not reintroduce `--landing-*` tokens, Martian Mono accents, or a separate marketing theme. + +## Styling - draw from the platform's light mode + +The landing page looks like the product. Its visual language is the workspace UI in light mode, not a separate marketing theme. + +- **Always light.** The root wrapper in `landing.tsx` carries the `light` class, which pins every token to its light value (see `app/_styles/globals.css`, the `:root, .light` block). Never add `dark:` variants here; never read the user's theme. +- **Use platform tokens, never hex.** Canvas `--bg`, surfaces `--surface-1`…`--surface-7`, cards/modals `--surface-2`, hover `--surface-hover`, active `--surface-active`; text `--text-primary` / `--text-secondary` / `--text-muted` / `--text-body`, icons `--text-icon`; borders `--border` (dividers) / `--border-1` (fields); brand `--brand-agent` / `--brand-secondary` / `--brand-accent`. Do **not** use the legacy `--landing-*` tokens - they belong to the old dark landing. +- **Use emcn components where they fit.** The chip family (`Chip`, `ChipLink`, `ChipTag`, `ChipInput`, `ChipModal*`, …) from `@/components/emcn` is the canonical chrome - a demo-request form is a `ChipModal` with `ChipModalField`s, a pill CTA is a `Chip`/`ChipLink`. Components own their chrome; pass props, not className overrides. Full consumer rules: `.claude/rules/sim-styling.md`. +- **Typography is the platform's.** Season is the global body font (`font-season` is applied on `` in the root layout). Use the platform text scale (`text-small` = 13px, `text-base` = 15px, etc. - see `tailwind.config.ts`). Don't add new fonts or font CSS variables without explicit direction. +- **Never touch global styles.** No additions to `app/_styles/globals.css`. All styling is local Tailwind classes; `cn()` from `@/lib/core/utils/cn` for conditionals; no inline `style` attributes. +- **Responsive - desktop is the source of truth, scaled down via `max-*` overrides.** The page is fully responsive (iPad + phone). The desktop layout stays the unprefixed baseline; smaller screens are handled by *layering* `max-*` overrides on top, so desktop renders byte-identically. Tiers: + - `max-xl:` (≤1279) - the hero's two-panel split (absolute visual + logos) collapses to a stacked, in-flow column. The split needs ≥1280 to avoid the headline colliding with the visual panel; iPad-landscape (1024) therefore gets the stacked hero with the desktop nav. + - `max-lg:` (≤1023) - the desktop nav clusters hide (`hidden lg:flex`) and `MobileNav` (hamburger sheet) takes over; multi-column grids step down (mothership 4→2, footer 7→3); shared gutter `px-12 → max-lg:px-8`; section gaps tighten. + - `max-md:` (≤767) - Features beats drop the floating callout (`max-md:hidden`) and show the un-masked backdrop preview full-width. + - `max-sm:` (≤639) - single-column grids, smallest type scale, `px-5` gutter, hero CTA row stacks. + + When adding a new section, give it the same `px-12 max-lg:px-8 max-sm:px-5` gutter so the navbar wordmark stays aligned with section content at every width. Verify desktop is unchanged and there is zero horizontal overflow at 1280 / 1024 / 768 / 390 before shipping. + +## Performance - page speed is a feature + +Target: Lighthouse 95+ on mobile, LCP < 2.0s, CLS < 0.05, minimal hydration cost. + +- **Server Components by default.** `'use client'` only on the smallest leaf that genuinely needs interactivity (a button with state, not the section containing it). The navbar, hero copy, footer, and every static section must be server-rendered HTML. +- **No heavy client libraries above the fold.** No animation frameworks (framer-motion etc.), no ReactFlow, no chart libs in the initial bundle. If a below-fold section truly needs one, load it with `next/dynamic` and a dimension-stable placeholder. +- **Images via `next/image` always.** The LCP element (logo or hero visual) gets `priority`; everything below the fold lazy-loads (the default). Every image has explicit `width`/`height` - zero layout shift. +- **Prefer CSS over JS.** Hover states, transitions, marquees, and reveal effects in CSS (`transition-*`, `animation`) rather than scroll listeners or animation libraries. Decorative motion respects `prefers-reduced-motion`. +- **Static rendering.** The page is statically generated with `revalidate` (set in `page.tsx`). Never fetch per-request data in the page tree; anything dynamic (e.g. GitHub stars) is fetched at build/revalidate time on the server or deferred to a tiny client island. +- **Reserve space for everything.** Fixed dimensions or aspect ratios on all media, embeds, and async content. CLS budget is effectively zero. + +## SEO + +`page.tsx` owns the metadata (title, description, OG/Twitter, canonical, robots) - keep it the single source of truth and keep it aligned with the constitution's claim hierarchy. Beyond metadata: + +- **One `

      `, in the hero, containing "Sim" and "AI workspace".** Strict hierarchy below it: H2 per section, H3 for items within a section. Never skip levels, never add a second H1. +- **Semantic landmarks**: `
      `, `
      `, `