From 1b30611363ca52f3d3f5db98690664fe8f4d5e93 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 18 Aug 2026 22:15:36 -0700 Subject: [PATCH 01/14] improvement(settings): drop the Delete account row's description (#6845) The confirmation modal already states the consequence, and states it better: it names the account, enumerates the workspaces that will be deleted, notes which billing transfers instead, marks 'This cannot be undone' in the error color, and requires typing the email to proceed. Nothing is lost by removing the line from the row. The wrapper it shared with the row goes too, now that the row is the section's only child. --- .../settings/components/general/general.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index 57e986f0aa7..4438d755b4a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -563,15 +563,9 @@ export function General() { {!isAuthDisabled && ( -
-
- - setShowDeleteAccountModal(true)}>Delete -
-

- Permanently deletes your account and everything only you can reach — workflows, - chats, files, knowledge bases and credentials. This cannot be undone. -

+
+ + setShowDeleteAccountModal(true)}>Delete
)} From 7dac31b06c797050809563daed1ab82a81f84edd Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 06:27:13 -0700 Subject: [PATCH 02/14] feat(library): AI Agent Orchestration Frameworks Explained (#6846) --- .../index.mdx | 121 ++++++++++++++++++ .../cover.jpg | Bin 0 -> 24098 bytes 2 files changed, 121 insertions(+) create mode 100644 apps/sim/content/library/ai-agent-orchestration-frameworks-explained/index.mdx create mode 100644 apps/sim/public/library/ai-agent-orchestration-frameworks-explained/cover.jpg diff --git a/apps/sim/content/library/ai-agent-orchestration-frameworks-explained/index.mdx b/apps/sim/content/library/ai-agent-orchestration-frameworks-explained/index.mdx new file mode 100644 index 00000000000..87fdc1afc48 --- /dev/null +++ b/apps/sim/content/library/ai-agent-orchestration-frameworks-explained/index.mdx @@ -0,0 +1,121 @@ +--- +slug: ai-agent-orchestration-frameworks-explained +title: 'AI Agent Orchestration Frameworks Explained' +description: 'Learn how AI agent orchestration frameworks coordinate models, tools, state, and control flow, and compare code-first, visual, and provider-native approaches.' +date: 2026-08-19 +updated: 2026-08-19 +authors: + - andrew +readingTime: 8 +tags: [AI Agents, Agent Orchestration, Workflow Automation, Sim] +ogImage: /library/ai-agent-orchestration-frameworks-explained/cover.jpg +canonical: https://www.sim.ai/library/ai-agent-orchestration-frameworks-explained +draft: false +faq: + - q: "How does agent orchestration differ from workflow automation?" + a: "Agent orchestration coordinates model reasoning, tool use, state, and control flow across one or more agents. Workflow automation primarily executes predefined steps. Modern systems can combine both approaches: models handle decisions that require context, while deterministic branches, loops, validations, and approvals govern predictable or sensitive actions." + - q: "Do agent orchestration frameworks require code?" + a: "Agent orchestration frameworks may use code, visual graphs, or natural-language instructions. Sim supports a visual canvas, natural-language building through Mothership, and programmatic interfaces. You can choose direct code control or build faster without writing every component yourself." + - q: "How does MCP fit into agent deployment?" + a: "MCP provides a standard way for AI systems to discover and call external tools. Sim can connect to external MCP servers and publish deployed workflows as MCP tools. You can make one workflow available to compatible assistants without building a separate integration for each one." + - q: "How do open-source, fair-code, and proprietary licenses differ?" + a: "Open-source licenses generally permit inspection, modification, and redistribution under stated conditions. Fair-code licenses make source available but may restrict some commercial uses, while proprietary licenses reserve broader control for the vendor. Review the exact license and enterprise terms before embedding a framework, offering it commercially, modifying it internally, or committing to a hosting model." +--- + +## TL;DR + +An AI agent orchestration framework is software that coordinates agents, models, tools, state, and control flow so they can complete multi-step work. + +- **Code-first frameworks** such as [LangGraph](https://docs.langchain.com/oss/python/langgraph/overview) give you direct control over orchestration logic in code. +- **Model-vendor-native stacks** such as the [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) integrate closely with one provider's models and tooling. +- **Visual and natural-language builders** such as Sim let you combine agent reasoning, explicit workflow logic, and code in one graph. Sim sits in this visual and code hybrid category. + +[Start building with Sim](https://sim.ai). + +## What an AI agent orchestration framework is + +An AI agent orchestration framework is software for defining how AI agents, models, tools, and human input work together across a task. It coordinates each step while preserving context, routing work, and controlling what happens next. + +Orchestration adds a runtime that single prompts and plain automation lack. A single prompt usually produces one response, while conventional automation follows a fixed sequence of predefined actions. An orchestration runtime tracks state across steps and uses a graph to represent the available actions and transitions. This combination is also central to an [agentic workflow](https://www.sim.ai/library/what-is-an-agentic-workflow). + +Most frameworks provide four core primitives. State stores messages, tool outputs, and other context that later steps need. Tool calling lets an agent query data or act through external systems. Control-flow operators route work and repeat steps, while approval nodes pause execution for human review. A deployment surface exposes the completed workflow through an API, chat interface, MCP server, or background job. + +Builders usually choose an approach based on how much infrastructure and control they want to own. Code-first frameworks expose orchestration logic directly, while visual and natural-language builders package it into an editor and managed runtime. Model-vendor-native stacks offer tighter integration with one provider's models and tool conventions. + +## The three ways teams build agent orchestration + +Code-first frameworks give you detailed control over agent state and execution, but you must write and maintain the surrounding infrastructure. + +Visual and natural-language builders speed up development while preserving varying levels of control over branches, loops, and approvals. + +Model-vendor-native stacks provide tight integration with one provider's models and tools, but they can limit model choice and increase ecosystem lock-in. + +### Code-first frameworks: LangGraph and CrewAI + +Code-first frameworks let engineers express agent orchestration directly in software, which gives them detailed control over state, routing, retries, and tool calls. They suit projects that need custom runtime behavior or must fit an existing application architecture. Engineers also gain access to normal development practices such as version control, automated tests, and code review. + +[LangGraph models an agent workflow as a stateful graph](https://docs.langchain.com/oss/python/langgraph/graph-api). Nodes run model calls, tools, or application logic, while edges determine which node runs next. A shared state object carries information between nodes. Because the graph can contain branches and cycles, LangGraph supports repeated reasoning, [human-in-the-loop interrupts](https://docs.langchain.com/oss/python/langgraph/interrupts), and long-running processes without forcing every decision into one prompt. + +[CrewAI organizes multi-agent orchestration](https://docs.crewai.com/en/concepts/crews) around agents with defined roles, goals, and tools. A crew assigns tasks to agents and controls how they collaborate, while [CrewAI Flows](https://docs.crewai.com/en/concepts/flows) add event-driven steps and state management around that collaboration. The role-based model works well when a workflow maps naturally to specialists, such as a researcher passing findings to a writer. + +The open-source frameworks do not provide a complete application by themselves. You may still need to assemble an end-user interface, deployment infrastructure, authentication, integrations, and production monitoring. [LangGraph Platform](https://docs.langchain.com/langgraph-platform/overview) and [CrewAI Enterprise](https://docs.crewai.com/en/enterprise/introduction) offer related services and tooling, but adopting those products introduces additional architecture and operating choices. + +Sim takes a different approach by packaging visual and natural-language building, deployment, and execution logs in one workspace. That approach can shorten the path between a prototype and an inspectable deployed system, while code-first frameworks preserve more control over implementation details. For a closer look at the tradeoffs, see these [LangGraph alternatives](https://www.sim.ai/library/langgraph-alternatives). + +### Visual and natural-language workflow builders: n8n, Make, Zapier, and Sim + +Visual and natural-language workflow builders let you assemble orchestration on a canvas instead of defining every state transition in code. You connect triggers, actions, branches, loops, and AI steps as blocks. Some products also let you create or edit those blocks through natural-language instructions. + +[n8n](https://docs.n8n.io/flow-logic/) and [Make](https://help.make.com/scenario-editor) center their builders on visual app automation, while [Zapier](https://help.zapier.com/hc/en-us/articles/8496181725453-Create-Zaps) uses trigger-and-action workflows. A trigger starts a predefined sequence, and each node processes data or calls another application. Their respective [AI Agent](https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.agent/), [Make AI Agents](https://help.make.com/make-ai-agents-new), and [Zapier Agents](https://help.zapier.com/hc/en-us/articles/30219476645645-Create-an-agent-in-Zapier-Agents) features add model decisions, tool use, and generated outputs within those products. Explicit flow controls govern what happens around each AI step. + +Sim gives model reasoning a first-class role within the same graph as deterministic control. A [Sim workflow](https://docs.sim.ai/introduction) can combine Agent blocks with functions, conditions, routers, loops, parallel branches, integration actions, and human approval. You can therefore reserve model judgment for tasks such as classifying a request while using fixed logic for validation, routing, and sensitive actions. + +[Mothership](https://docs.sim.ai/mothership) adds natural-language control across the Sim workspace. You can ask it to create or modify workflows, work with tables and files, connect knowledge bases, and set up recurring jobs. The visual canvas remains available for inspecting the resulting graph and adjusting individual blocks. + +Sim also connects orchestration to deployment. According to its [deployment documentation](https://docs.sim.ai/workflows/deployment), a versioned workflow can run through a REST API or hosted chat, and its [MCP deployment](https://docs.sim.ai/workflows/deployment/mcp) can expose a deployed workflow as a tool. Those options let one graph serve an application backend, a conversational interface, or another AI system without rebuilding its orchestration logic for each surface. + +Choose this category when you want faster construction and easier inspection than a code-first framework provides. The main differences concern how deeply each builder supports agent reasoning, how much deterministic control remains available, and how you can deploy the finished workflow. This [OpenAI, n8n, and Sim comparison](https://www.sim.ai/library/openai-vs-n8n-vs-sim) examines those distinctions in more detail. + +### Model-vendor-native stacks: OpenAI Agents SDK + +Provider-native stacks package agent orchestration around one company's models, APIs, and tool conventions. The [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) combines function tools, built-in tools, agent handoffs, guardrails, sessions, and tracing. You define agent instructions and available tools, while the stack manages model calls and the loop that selects and executes those tools. + +OpenAI maintains the models and orchestration interfaces together, so new model capabilities can reach the OpenAI Agents SDK without a separate compatibility layer. The SDK also lets you add application logic around agent runs, including [conditional routing and handoffs](https://openai.github.io/openai-agents-python/handoffs/) between specialized agents. + +The main tradeoff concerns model and deployment choice. An application built around OpenAI-specific tools, response objects, and tracing requires extra work to support another provider or a self-hosted model. You still need to host the surrounding application unless a separate OpenAI product supplies the user interface or runtime surface. + +You may choose the OpenAI Agents SDK when your application already standardizes on OpenAI models and you want direct access to their tool-calling behavior. A model-neutral framework usually fits better when you expect to switch providers, run local models, or deploy the same workflow across several runtime environments. + +## Best AI agent orchestration frameworks in 2026 + +The table compares seven prominent orchestration options across five decision criteria: builder model, agent depth, deterministic control, deployment surfaces, and self-hosting or license model. + +The product capabilities and license classifications below are current as of August 2026. LangGraph and CrewAI use the permissive MIT license; n8n uses its fair-code Sustainable Use License; Sim's core uses Apache 2.0; and the OpenAI Agents SDK is MIT-licensed, while related hosted OpenAI services remain proprietary. Make and Zapier are proprietary cloud products. Review the linked license or terms pages for the current legal text before making a procurement or distribution decision. + +| Framework | Builder model | Agent depth | Deterministic control | Deployment surfaces | Self-hosting and license | +| --- | --- | --- | --- | --- | --- | +| [LangGraph](https://docs.langchain.com/oss/python/langgraph/graph-api) | ✅ Code-first graph | ✅ Stateful agent runtime | ✅ Nodes, branches, loops | 🟡 App or API | ✅ Self-hosted, [MIT](https://github.com/langchain-ai/langgraph/blob/main/LICENSE) | +| [CrewAI](https://docs.crewai.com/en/concepts/crews) | ✅ Code-first roles and tasks | ✅ Multi-agent runtime | 🟡 [Flows and task routing](https://docs.crewai.com/en/concepts/flows) | 🟡 App or API | ✅ Self-hosted, [MIT](https://github.com/crewAIInc/crewAI/blob/main/LICENSE) | +| [n8n](https://docs.n8n.io/advanced-ai/) | ✅ Visual workflow canvas | 🟡 Agents within automation | ✅ [Branches and loops](https://docs.n8n.io/flow-logic/) | ✅ [Webhooks](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/), [API](https://docs.n8n.io/api/), [chat](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.chattrigger/) | 🟡 Self-hosted, [fair-code Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) | +| [Make](https://help.make.com/scenario-editor) | ✅ Visual workflow canvas | 🟡 [AI inside automation](https://help.make.com/make-ai-agents-new) | ✅ [Routes, filters, iterators](https://help.make.com/router) | 🟡 [Webhooks](https://help.make.com/webhooks) and [API](https://developers.make.com/api-documentation) | ❌ [Proprietary cloud](https://www.make.com/en/terms-and-conditions) | +| [Zapier](https://help.zapier.com/hc/en-us/articles/8496181725453-Create-Zaps) | ✅ Visual and natural language | 🟡 [Agents plus app automation](https://help.zapier.com/hc/en-us/articles/30219476645645-Create-an-agent-in-Zapier-Agents) | 🟡 [Paths](https://help.zapier.com/hc/en-us/articles/8496277737997-Add-branching-logic-to-Zaps-with-paths) and [approval steps](https://zapier.com/apps/approval/integrations) | 🟡 [Apps](https://zapier.com/apps), [agent chat](https://help.zapier.com/hc/en-us/articles/30219476645645-Create-an-agent-in-Zapier-Agents), [webhooks](https://help.zapier.com/hc/en-us/articles/8496326446989-Send-webhooks-in-Zaps) | ❌ [Proprietary cloud](https://zapier.com/tos) | +| Sim | ✅ Natural language, visual, API | ✅ Agent-native runtime | ✅ Branches, loops, approvals | ✅ [API, hosted chat, MCP](https://docs.sim.ai/workflows/deployment) | ✅ Self-hosted, [Apache 2.0 core](https://github.com/simstudioai/sim) | +| [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) | ✅ Code-first SDK | ✅ Native agents and handoffs | 🟡 Code-defined routing and guardrails | 🟡 App-managed APIs and interfaces | 🟡 [MIT-licensed SDK](https://github.com/openai/openai-agents-python/blob/main/LICENSE), proprietary services | + +## Choosing the right orchestration approach + +Code-first frameworks suit you when orchestration behavior must live in your application code. [LangGraph](https://docs.langchain.com/oss/python/langgraph/overview) and [CrewAI](https://docs.crewai.com/) let engineers define state, agent roles, tool calls, and failure handling directly. You gain low-level control, but you must often assemble deployment, interfaces, and monitoring separately. + +Visual and natural-language builders suit you when build speed and operational visibility take priority. Platforms such as [n8n](https://docs.n8n.io/), [Make](https://help.make.com/), [Zapier](https://help.zapier.com/hc/en-us), and Sim expose workflow logic on a canvas. Compare their documented agent runtime depth, approval controls, and production surfaces. For example, Sim's [deployment surfaces](https://docs.sim.ai/workflows/deployment) include an API and hosted chat, while its [MCP support](https://docs.sim.ai/workflows/deployment/mcp) can publish a deployed workflow as a tool. + +Model-vendor-native stacks suit you when your application already depends on one provider. The [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) closely supports OpenAI models and tool-calling conventions, but changing providers may require more rework. + +Licensing can decide the category before features do. Permissive open-source licenses such as [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0) provide broad rights to modify and distribute software. Fair-code licenses may restrict commercial use, while proprietary products keep source code and hosting control with the vendor. Check the exact license and enterprise terms if you need self-hosting, internal modifications, or resale rights. For a broader selection, review these [open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms). + +## Where Sim fits in this landscape + +Sim combines agent reasoning and deterministic control in one visual workflow graph. You can place model decisions beside explicit branches, code, and human approvals, which keeps uncertain tasks within defined operating rules. The same versioned workflow can run through an API or hosted chat. Sim can also [publish it as an MCP tool](https://docs.sim.ai/workflows/deployment/mcp). + +Sim's core uses the permissive [Apache 2.0 license](https://github.com/simstudioai/sim), which supports inspection, modification, and self-hosting. Readers evaluating that final deployment option can follow this guide to [turn a workflow into a reusable MCP tool](https://www.sim.ai/library/how-to-turn-a-workflow-into-a-reusable-mcp-tool). + +To build your first workflow, follow the [Sim documentation quickstart](https://docs.sim.ai/quickstart). diff --git a/apps/sim/public/library/ai-agent-orchestration-frameworks-explained/cover.jpg b/apps/sim/public/library/ai-agent-orchestration-frameworks-explained/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8b93e0a39b6a28676a335cbaf51fcc61e8b181fb GIT binary patch literal 24098 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 literal 0 HcmV?d00001 From dafa4dadda71fbed4494c3e384efb65573f12fea Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 19 Aug 2026 10:15:21 -0700 Subject: [PATCH 03/14] fix(settings): drop the settings return url when the workspace changed (#6847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings Back button restores a return url captured on entry, but a workspace switch made from inside settings keeps the user in the new workspace without touching that stored path — so Back pushed them back into the workspace they had left, while the sidebar still read as the new one. Discard a stored return url that names a different workspace and fall back to the current workspace root. --- apps/sim/hooks/use-oauth-return.ts | 2 +- .../sim/hooks/use-settings-navigation.test.ts | 42 +++++++++++++++- apps/sim/hooks/use-settings-navigation.ts | 48 +++++++++++++++---- 3 files changed, 80 insertions(+), 12 deletions(-) diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index cd595b345b9..a79f4a8297c 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -26,9 +26,9 @@ import { getDesktopBridge } from '@/lib/desktop' import { oauthConnectionsKeys } from '@/hooks/queries/oauth/oauth-connections' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { requireWorkspaceCredentialListResponse } from '@/hooks/queries/utils/fetch-workspace-credentials' +import { SETTINGS_RETURN_URL_KEY } from '@/hooks/use-settings-navigation' const OAUTH_CREDENTIAL_UPDATED_EVENT = 'oauth-credentials-updated' -const SETTINGS_RETURN_URL_KEY = 'settings-return-url' const CONTEXT_MAX_AGE_MS = 15 * 60 * 1000 export interface OAuthResultMessage { diff --git a/apps/sim/hooks/use-settings-navigation.test.ts b/apps/sim/hooks/use-settings-navigation.test.ts index e2839fe0a22..2d10a0a98a3 100644 --- a/apps/sim/hooks/use-settings-navigation.test.ts +++ b/apps/sim/hooks/use-settings-navigation.test.ts @@ -15,7 +15,7 @@ vi.mock('@/lib/auth/auth-client', () => ({ useSession: vi.fn(() => ({ data: null, isPending: false })), })) -import { resolveSettingsHref } from '@/hooks/use-settings-navigation' +import { resolveSettingsHref, resolveSettingsReturnUrl } from '@/hooks/use-settings-navigation' const HOST_CONTEXT: WorkspaceHostContext = { workspace: { @@ -107,3 +107,43 @@ describe('resolveSettingsHref unified settings navigation', () => { ).toBe('/workspace/workspace-b/settings/billing') }) }) + +describe('resolveSettingsReturnUrl', () => { + const fallback = '/workspace/workspace-b' + + it('returns the stored url when it belongs to the current workspace', () => { + expect( + resolveSettingsReturnUrl({ + storedUrl: '/workspace/workspace-b/w/workflow-a', + workspaceId: 'workspace-b', + fallback, + }) + ).toBe('/workspace/workspace-b/w/workflow-a') + }) + + it('discards a stored url captured in a workspace the user has since left', () => { + expect( + resolveSettingsReturnUrl({ + storedUrl: '/workspace/workspace-a/w/workflow-a', + workspaceId: 'workspace-b', + fallback, + }) + ).toBe(fallback) + }) + + it('keeps workspace-agnostic stored urls', () => { + expect( + resolveSettingsReturnUrl({ + storedUrl: '/account/settings/billing', + workspaceId: 'workspace-b', + fallback, + }) + ).toBe('/account/settings/billing') + }) + + it('falls back when nothing was stored', () => { + expect( + resolveSettingsReturnUrl({ storedUrl: null, workspaceId: 'workspace-b', fallback }) + ).toBe(fallback) + }) +}) diff --git a/apps/sim/hooks/use-settings-navigation.ts b/apps/sim/hooks/use-settings-navigation.ts index 532ffb84bc7..4b2d0635510 100644 --- a/apps/sim/hooks/use-settings-navigation.ts +++ b/apps/sim/hooks/use-settings-navigation.ts @@ -8,7 +8,7 @@ import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' -const SETTINGS_RETURN_URL_KEY = 'settings-return-url' +export const SETTINGS_RETURN_URL_KEY = 'settings-return-url' interface SettingsNavigationOptions { section?: SettingsSection @@ -57,6 +57,31 @@ export function resolveSettingsHref({ return query ? `${pathname}?${query}` : pathname } +interface ResolveSettingsReturnUrlParams { + storedUrl: string | null + workspaceId?: string + fallback: string +} + +/** + * Resolves the stored settings return url, discarding it when it points at a + * different workspace than the one currently open. Switching workspaces from + * settings keeps the user on the new workspace, so a return url captured in the + * old one would silently navigate them back out of it. + */ +export function resolveSettingsReturnUrl({ + storedUrl, + workspaceId, + fallback, +}: ResolveSettingsReturnUrlParams): string { + if (!storedUrl) return fallback + const [, root, storedWorkspaceId] = storedUrl.split('/') + if (root === 'workspace' && storedWorkspaceId && storedWorkspaceId !== workspaceId) { + return fallback + } + return storedUrl +} + export function useSettingsNavigation(): UseSettingsNavigationReturn { const router = useRouter() const params = useParams<{ workspaceId?: string }>() @@ -77,15 +102,18 @@ export function useSettingsNavigation(): UseSettingsNavigationReturn { [hostContext, session?.user?.id, workspaceId] ) - const popSettingsReturnUrl = useCallback((fallback: string): string => { - try { - const url = sessionStorage.getItem(SETTINGS_RETURN_URL_KEY) - sessionStorage.removeItem(SETTINGS_RETURN_URL_KEY) - return url ?? fallback - } catch { - return fallback - } - }, []) + const popSettingsReturnUrl = useCallback( + (fallback: string): string => { + try { + const storedUrl = sessionStorage.getItem(SETTINGS_RETURN_URL_KEY) + sessionStorage.removeItem(SETTINGS_RETURN_URL_KEY) + return resolveSettingsReturnUrl({ storedUrl, workspaceId, fallback }) + } catch { + return fallback + } + }, + [workspaceId] + ) const navigateToSettings = useCallback( (options?: SettingsNavigationOptions) => { From 10ff62259f1674800f309fcf1f3988ece21911df Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 19 Aug 2026 10:23:51 -0700 Subject: [PATCH 04/14] fix(connectors): treat a zero-byte source file as nothing to index (#6848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed in production after connectors began delivering source files: a zero-byte PDF was stored and shipped to OCR, which answered `400 Bad Request`. That bills an external call to discover the file was empty and reports it as an API fault rather than as what it is. Before source files existed, an empty file produced empty extracted text and was dropped at the empty-content check, so this was a regression. The emptiness rule now lives in one place, `hasIndexablePayload`, used by the sync engine's classify and hydrate gates and by both connectors' `getDocument`. It previously existed twice — the connectors asked whether a source file was present while the sync engine asked the same question a second way — and a source file with no bytes satisfied both. --- apps/sim/connectors/onedrive/onedrive.ts | 3 +- apps/sim/connectors/sharepoint/sharepoint.ts | 3 +- apps/sim/connectors/utils.test.ts | 31 +++++++++++++++++++ apps/sim/connectors/utils.ts | 15 +++++++++ .../lib/knowledge/connectors/sync-engine.ts | 10 ++---- 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/apps/sim/connectors/onedrive/onedrive.ts b/apps/sim/connectors/onedrive/onedrive.ts index 63a427d59d5..6e25767f5ef 100644 --- a/apps/sim/connectors/onedrive/onedrive.ts +++ b/apps/sim/connectors/onedrive/onedrive.ts @@ -8,6 +8,7 @@ import { ConnectorFileTooLargeError, connectorFileExtension, extractConnectorText, + hasIndexablePayload, isIndexableConnectorFile, isSkippedDocument, markSkipped, @@ -383,7 +384,7 @@ export const onedriveConnector: ConnectorConfig = { try { const payload = await fetchFilePayload(accessToken, item.id, item.name) - if (!payload.sourceFile && !payload.content.trim()) return null + if (!hasIndexablePayload(payload)) return null const stub = fileToStub(item) return { ...stub, ...payload, contentDeferred: false } diff --git a/apps/sim/connectors/sharepoint/sharepoint.ts b/apps/sim/connectors/sharepoint/sharepoint.ts index 3ebd325555b..8a7fb352d29 100644 --- a/apps/sim/connectors/sharepoint/sharepoint.ts +++ b/apps/sim/connectors/sharepoint/sharepoint.ts @@ -8,6 +8,7 @@ import { ConnectorFileTooLargeError, connectorFileExtension, extractConnectorText, + hasIndexablePayload, isIndexableConnectorFile, isSkippedDocument, markSkipped, @@ -931,7 +932,7 @@ export const sharepointConnector: ConnectorConfig = { try { const payload = await fetchFilePayload(accessToken, driveId, item.id, item.name) - if (!payload.sourceFile && !payload.content.trim()) return null + if (!hasIndexablePayload(payload)) return null const stub = itemToStub(item, siteName ?? siteUrl) return { ...stub, ...payload, contentDeferred: false } diff --git a/apps/sim/connectors/utils.test.ts b/apps/sim/connectors/utils.test.ts index 6ba40e5200a..ca786fadc1d 100644 --- a/apps/sim/connectors/utils.test.ts +++ b/apps/sim/connectors/utils.test.ts @@ -63,6 +63,7 @@ import { typeformConnector } from '@/connectors/typeform/typeform' import { ConnectorFileTooLargeError, extractConnectorText, + hasIndexablePayload, htmlToPlainText, isIndexableConnectorFile, isSkippedDocument, @@ -1482,3 +1483,33 @@ describe('pipelineParsedMimeType', () => { expect(pipelineParsedMimeType('README')).toBeUndefined() }) }) + +describe('hasIndexablePayload', () => { + const bytes = (value: string) => ({ + bytes: Buffer.from(value), + fileName: 'Report.pdf', + mimeType: 'application/pdf', + }) + + it('accepts a source file with bytes', () => { + expect(hasIndexablePayload({ content: '', sourceFile: bytes('%PDF') })).toBe(true) + }) + + it('accepts extracted text', () => { + expect(hasIndexablePayload({ content: 'notes' })).toBe(true) + }) + + /** + * Observed in production: a zero-byte PDF was stored and shipped to OCR, which + * answered `400 Bad Request` — an external call billed to discover the file was + * empty, reported as an API fault rather than as an empty file. Before source + * files existed this was dropped at the empty-content check. + */ + it('rejects a zero-byte source file rather than sending it to OCR', () => { + expect(hasIndexablePayload({ content: '', sourceFile: bytes('') })).toBe(false) + }) + + it('rejects blank text', () => { + expect(hasIndexablePayload({ content: ' ' })).toBe(false) + }) +}) diff --git a/apps/sim/connectors/utils.ts b/apps/sim/connectors/utils.ts index 608de615bd1..63674ae350c 100644 --- a/apps/sim/connectors/utils.ts +++ b/apps/sim/connectors/utils.ts @@ -226,6 +226,21 @@ export function isIndexableConnectorFile(fileName: string): boolean { return extension !== undefined && CONNECTOR_INDEXABLE_EXTENSIONS.has(extension) } +/** + * Whether a document carries anything worth indexing. + * + * A source file has to have bytes. A zero-byte file is not payload: it produces an + * empty stored object, and for a PDF that reaches OCR as an empty request and comes + * back as an opaque `400 Bad Request` — billing an external call to learn the file + * was empty, and reporting it as an API fault rather than as what it is. + */ +export function hasIndexablePayload( + doc: Pick +): boolean { + if (doc.sourceFile) return doc.sourceFile.bytes.length > 0 + return doc.content.trim().length > 0 +} + /** * MIME type to store a file under when the shared pipeline should parse it, or * `undefined` when the connector should decode it as text itself. diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 14e3bb7f13a..c35f3b3d303 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -33,6 +33,7 @@ import type { ExternalDocument, SyncResult, } from '@/connectors/types' +import { hasIndexablePayload } from '@/connectors/utils' const logger = createLogger('ConnectorSyncEngine') @@ -157,7 +158,7 @@ export function classifyExternalDoc( if (extDoc.skippedReason) { return existing ? { type: 'unchanged' } : { type: 'skip' } } - if (!hasPayload(extDoc) && !extDoc.contentDeferred) { + if (!hasIndexablePayload(extDoc) && !extDoc.contentDeferred) { return { type: 'drop' } } if (!existing) { @@ -203,11 +204,6 @@ export function mergeHydratedDocument( } } -/** Whether a document carries anything to index — extracted text or the source file. */ -function hasPayload(extDoc: Pick): boolean { - return extDoc.sourceFile !== undefined || extDoc.content.trim().length > 0 -} - /** Estimated source bytes for a pending op, taken from its listing metadata. */ function estimateOpSizeBytes(op: DocOp): number { // Skip ops load no content (just a row insert), so they do not count against the @@ -1093,7 +1089,7 @@ export async function executeSync( } return null } - if (!fullDoc || !hasPayload(fullDoc)) { + if (!fullDoc || !hasIndexablePayload(fullDoc)) { // An empty re-fetch leaves an already-indexed update as last-known-good; count // it as unchanged so the totals still reconcile with documents seen. Not a // verified refresh, though — see failedExternalIds below. From cb6c842e27aefd0a41db2ac6212b2806a9786ec3 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 19 Aug 2026 11:30:55 -0700 Subject: [PATCH 05/14] feat(knowledge): read a PDF's text layer before paying for OCR (#6850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(knowledge): read a PDF's text layer before paying for OCR Every PDF went to OCR, an external per-document call, even though most carry an embedded text layer that costs nothing to read. Across a real corpus of 2,693 documents, local extraction produced text for every PDF that OCR could also read, so the great majority of those calls bought nothing. A PDF's text layer is now read first and used when it is good enough, leaving OCR for the documents that actually need it. Three ways a layer fails, none of which catches the others: there is no text at all (a scan), the text is too sparse to be the document, or there is plenty of text that is not language — a broken encoding, or the raw character ids a CID-keyed font emits with no ToUnicode map, which is common in exactly the contract and procurement material that reaches a knowledge base and which a length check alone reads as healthy. Beyond the cost, this narrows an availability dependency: an OCR outage no longer touches every PDF, only the minority that cannot be read locally. The threshold is env-tunable so the balance can be moved toward cost or fidelity without a deploy. Known limitation: the judgement is per document, so a file mixing typeset pages with scanned inserts can average above the threshold and keep its partial text. Per-page routing would catch it and needs per-page extraction this does not have. The opaque-input refusal now asserts against the outbound request rather than the storage read: local parsing is not model input, so bytes are read before the projection is checked and still never leave the worker when it refuses. * fix(knowledge): route a truncated PDF extraction to OCR, and drop the threshold env var Two corrections to the text-layer triage. A parser limit stops extraction partway and reports `truncated`. Such a result has plenty of text by volume, so every volume-based check read it as healthy and the document was indexed as a fragment with the remainder silently missing from search. Truncation is now judged before anything that measures volume, and sends the document to OCR, which reads it whole. The characters-per-page threshold is a plain constant again. It read `process.env` directly rather than going through the env module, and the tunable was not worth having: a typeset page carries roughly 1,500-3,000 characters and a scan carries none, so the value sits in a wide gap where no realistic tuning changes an outcome. A constant is one less piece of configuration that can be set wrong, and if the threshold is ever wrong the fix is to change it. * fix(knowledge): take the page count from the parse that produced the text The density check counted pages with a second, independent read of the file. The two could disagree: a count that failed reported no pages, the check fell back to treating the document as a single page, and a long scan carrying only a header looked dense enough to skip OCR and be indexed as that header. `parseBuffer` already reports the page count from the parse that produced the text, so the two can no longer diverge, and the redundant second open of the file goes away with it. * fix(knowledge): chunk a long PDF for Azure OCR instead of refusing it Both OCR providers cap how many pages a single request may carry, and both were handling that cap differently: one split the document to fit, the other rejected any document over it. A long PDF could therefore be ingested on one provider and not at all on the other, for a limit that belongs to a request rather than to a document. The splitting, concurrency, ordering and partial-failure rule now live in one place that both providers call, so they cannot drift apart again. A chunk that fails is dropped rather than failing the document — losing one section of a long document beats losing all of it — and every chunk failing still throws. Also drops the unpdf mock from the triage tests. It was masking real behaviour: the page count now comes from the parse metadata, so the mock was no longer needed, and while it was in place a test asserting the old page-cap refusal passed against both the old and new code. * fix(knowledge): keep an unsplittable PDF and an empty OCR response honest Two regressions from chunking the Azure path. Splitting loads the document, which an encrypted or malformed PDF refuses, and that failure was deciding whether the file reached OCR at all. Those are exactly the documents the triage routes here — no readable text layer — and the provider may well accept bytes a local parser will not, so a failed split now sends the document whole and leaves the page cap to the provider, as it did before it was chunked. An Azure response carrying no pages fell back to the raw API payload as content. Chunked, that payload counted as recovered text and was stitched into the document; unchunked, it satisfied the empty-content check written to catch this. No pages is now no content, so the chunk counts as failed and the document reports it. * fix(knowledge): fail a PDF whose OCR only partly came back A chunked OCR run dropped any chunk that failed and returned the rest as a normal success, so the document was marked complete with whole page ranges absent from search and nothing downstream could tell the difference. That contradicted the rule this change set already applies to a truncated text layer, which is sent to OCR precisely because indexing a fragment while reporting success is the failure being removed. A document is now indexed whole or not at all: any missing chunk fails it, leaving it visible with a reason and eligible for the stuck-document sweep, which can retry and produce a complete result. Each chunk has already exhausted its own retries, so a missing one is a real failure rather than a blip. The page-cap test mocked fetch with a single Response object, whose body can only be read once — the second chunk was failing on "Body already read" and the lenient path hid it. It now returns a fresh response per call. --- ...cument-processor-secret-provenance.test.ts | 12 +- .../knowledge/documents/document-processor.ts | 301 +++++++++++++----- .../documents/pdf-ocr-triage.test.ts | 272 ++++++++++++++++ .../documents/pdf-text-layer.test.ts | 87 +++++ .../lib/knowledge/documents/pdf-text-layer.ts | 104 ++++++ 5 files changed, 694 insertions(+), 82 deletions(-) create mode 100644 apps/sim/lib/knowledge/documents/pdf-ocr-triage.test.ts create mode 100644 apps/sim/lib/knowledge/documents/pdf-text-layer.test.ts create mode 100644 apps/sim/lib/knowledge/documents/pdf-text-layer.ts diff --git a/apps/sim/lib/knowledge/documents/document-processor-secret-provenance.test.ts b/apps/sim/lib/knowledge/documents/document-processor-secret-provenance.test.ts index 75751211211..d3386f69946 100644 --- a/apps/sim/lib/knowledge/documents/document-processor-secret-provenance.test.ts +++ b/apps/sim/lib/knowledge/documents/document-processor-secret-provenance.test.ts @@ -91,7 +91,17 @@ describe('knowledge document model-input provenance', () => { expect(fetchMock).not.toHaveBeenCalled() }) + /** + * The refusal guards egress to an external model, so it is asserted against the + * outbound request rather than the storage read. A PDF is now parsed locally + * first and only reaches OCR when it has no usable text layer — local parsing is + * not model input, as the case above establishes — so the bytes are read before + * the projection is checked, and never leave the worker when it refuses. + */ it('rejects secret-bearing opaque document bytes before external OCR', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + await expect( runWithKnowledgeModelInputProvenance( undefined, @@ -109,7 +119,7 @@ describe('knowledge document model-input provenance', () => { ) ).rejects.toThrow('Knowledge model input could not be safely projected') - expect(mockDownloadFileFromUrl).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() }) it('attaches exact-empty provenance to the internal Mistral OCR request', async () => { diff --git a/apps/sim/lib/knowledge/documents/document-processor.ts b/apps/sim/lib/knowledge/documents/document-processor.ts index 542b84af54c..ebea0141279 100644 --- a/apps/sim/lib/knowledge/documents/document-processor.ts +++ b/apps/sim/lib/knowledge/documents/document-processor.ts @@ -22,6 +22,7 @@ import { resolveParserExtension, resolveStoredArtifactExtension, } from '@/lib/knowledge/documents/parser-extension' +import { assessPdfTextLayer } from '@/lib/knowledge/documents/pdf-text-layer' import { retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils' import { assertKnowledgeOpaqueModelInputSafe, @@ -295,6 +296,68 @@ async function getMistralApiKey(workspaceId?: string | null): Promise { + try { + const buffer = await downloadFileWithTimeout(fileUrl, userId) + const parsed = await parseBuffer(buffer, 'pdf') + + /** + * The page count comes from the same parse as the text, rather than a second + * independent read of the file. Counting separately lets the two disagree: a + * count that failed would report no pages, the density check would fall back to + * treating the document as a single page, and a long scan carrying only a header + * would look dense enough to skip OCR and be indexed as that header. + */ + const pageCount = parsed.metadata?.pageCount ?? 0 + const verdict = assessPdfTextLayer(parsed.content, pageCount, parsed.metadata?.truncated) + if (!verdict.usable) { + logger.info('PDF text layer not usable, routing to OCR', { + filename, + pageCount, + reason: verdict.reason, + }) + return undefined + } + + logger.info('Using embedded PDF text layer', { filename, pageCount }) + return { + content: parsed.content, + processingMethod: 'file-parser', + cloudUrl: undefined, + metadata: parsed.metadata, + } + } catch (error) { + logger.info('Could not read PDF text layer, routing to OCR', { + filename, + mimeType, + error: toError(error).message, + }) + return undefined + } +} + async function parseDocument( fileUrl: string, filename: string, @@ -319,14 +382,23 @@ async function parseDocument( MISTRAL_API_KEY: mistralApiKey, }).providerId - if (ocrProvider === 'azure-mistral') { - assertKnowledgeOpaqueModelInputSafe() - logger.info('Using Azure Mistral OCR') - return parseWithAzureMistralOCR(fileUrl, filename, mimeType, userId) - } + if (ocrProvider === 'azure-mistral' || ocrProvider === 'mistral') { + /** + * Most PDFs carry a usable text layer, and reading it costs nothing. OCR is + * a per-document call to an external service, so it is reserved for the + * documents that actually need it — which also means everything else stops + * depending on that service being reachable. + */ + const embedded = await readEmbeddedPdfText(fileUrl, filename, mimeType, userId) + if (embedded) return embedded - if (ocrProvider === 'mistral') { assertKnowledgeOpaqueModelInputSafe() + + if (ocrProvider === 'azure-mistral') { + logger.info('Using Azure Mistral OCR') + return parseWithAzureMistralOCR(fileUrl, filename, mimeType, userId) + } + logger.info('Using Mistral OCR') return parseWithMistralOCR(fileUrl, filename, mimeType, userId, workspaceId, mistralApiKey) } @@ -522,42 +594,19 @@ async function parseWithAzureMistralOCR( const fileBuffer = await downloadFileForBase64(fileUrl, userId) - if (mimeType === 'application/pdf') { - const pageCount = await getPdfPageCount(fileBuffer) - if (pageCount > MISTRAL_MAX_PAGES) { - throw new Error( - `PDF has ${pageCount} pages, exceeding the Azure OCR limit of ${MISTRAL_MAX_PAGES}` - ) - } - logger.info('Azure Mistral OCR: PDF page count resolved', { pageCount }) - } - - const base64Data = fileBuffer.toString('base64') - const dataUri = `data:${mimeType};base64,${base64Data}` - try { - const response = await retryWithExponentialBackoff( - () => - makeOCRRequest( - env.OCR_AZURE_ENDPOINT!, - { - 'Content-Type': 'application/json', - Authorization: `Bearer ${env.OCR_AZURE_API_KEY}`, - }, - { - model: env.OCR_AZURE_MODEL_NAME!, - document: { - type: 'document_url', - document_url: dataUri, - }, - include_image_base64: false, - } - ), - { maxRetries: 3, initialDelayMs: 1000, maxDelayMs: 10000 } - ) - - const ocrResult = (await response.json()) as AzureOCRResponse - const content = extractPageContent(ocrResult.pages || []) || JSON.stringify(ocrResult, null, 2) + /** + * A PDF is chunked to the provider's page cap rather than refused for + * exceeding it, matching the other OCR provider. Refusing meant a long + * document could not be ingested at all, and the cap applies to a single + * request, not to the document. + */ + const content = + mimeType === 'application/pdf' + ? await ocrPdfInChunks(fileBuffer, 'azure-mistral', (chunk) => + recognizeWithAzureOCR(chunk.buffer, mimeType) + ) + : await recognizeWithAzureOCR(fileBuffer, mimeType) if (!content.trim()) { throw new Error('Azure Mistral OCR returned empty content') @@ -573,6 +622,41 @@ async function parseWithAzureMistralOCR( } } +/** Sends one document to Azure Mistral OCR inline, as a base64 data URI. */ +async function recognizeWithAzureOCR(buffer: Buffer, mimeType: string): Promise { + const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}` + + const response = await retryWithExponentialBackoff( + () => + makeOCRRequest( + env.OCR_AZURE_ENDPOINT!, + { + 'Content-Type': 'application/json', + Authorization: `Bearer ${env.OCR_AZURE_API_KEY}`, + }, + { + model: env.OCR_AZURE_MODEL_NAME!, + document: { + type: 'document_url', + document_url: dataUri, + }, + include_image_base64: false, + } + ), + { maxRetries: 3, initialDelayMs: 1000, maxDelayMs: 10000 } + ) + + const ocrResult = (await response.json()) as AzureOCRResponse + + /** + * A response carrying no pages is no content. Returning the raw payload instead + * would be indexed as though it were the document: stitched into a chunked run + * as recovered text, and in a single-document run it would satisfy the + * empty-content check that exists to catch exactly this. + */ + return extractPageContent(ocrResult.pages || []) +} + async function parseWithMistralOCR( fileUrl: string, filename: string, @@ -740,63 +824,118 @@ async function processChunk( } } -async function processMistralOCRInBatches( - filename: string, - apiKey: string, +/** + * Runs a PDF through OCR a chunk at a time and stitches the pages back together. + * + * A provider that caps how many pages one request may carry needs the document + * split, and both providers cap at the same limit — so the splitting, the + * concurrency, the ordering and the partial-failure rule live here once rather + * than being restated per provider, where they had already drifted into one + * provider chunking and the other refusing anything over the cap. + * + * A document is indexed whole or not at all: if any chunk fails, the document + * fails, because a partial result reports success while page ranges are missing + * and nothing downstream can tell. + */ +async function ocrPdfInChunks( pdfBuffer: Buffer, - userId?: string, - cloudUrl?: string -): Promise<{ - content: string - processingMethod: 'mistral-ocr' - cloudUrl?: string -}> { + provider: string, + recognize: ( + chunk: { buffer: Buffer; startPage: number; endPage: number }, + chunkIndex: number, + totalChunks: number + ) => Promise +): Promise { const totalPages = await getPdfPageCount(pdfBuffer) - logger.info(`Splitting PDF into chunks`, { totalPages, maxPagesPerChunk: MISTRAL_MAX_PAGES }) - const pdfChunks = await splitPdfIntoChunks(pdfBuffer, MISTRAL_MAX_PAGES) - logger.info( - `Split into ${pdfChunks.length} chunks, processing with concurrency ${MAX_CONCURRENT_CHUNKS}` - ) + /** + * Splitting has to load the document, which an encrypted or malformed PDF will + * refuse. That must not decide whether the file reaches OCR at all: those are + * exactly the documents with no readable text layer, so OCR is their only route, + * and the provider may well accept bytes that a local parser would not. When the + * split fails the document is sent whole and the page cap is left to the + * provider — the behaviour before it was chunked. + */ + let pdfChunks: { buffer: Buffer; startPage: number; endPage: number }[] + try { + pdfChunks = await splitPdfIntoChunks(pdfBuffer, MISTRAL_MAX_PAGES) + } catch (error) { + logger.info('PDF could not be split for OCR, sending it whole', { + provider, + error: toError(error).message, + }) + pdfChunks = [{ buffer: pdfBuffer, startPage: 0, endPage: Math.max(0, totalPages - 1) }] + } + + logger.info('Splitting PDF for OCR', { + provider, + totalPages, + chunks: pdfChunks.length, + maxPagesPerChunk: MISTRAL_MAX_PAGES, + concurrency: MAX_CONCURRENT_CHUNKS, + }) const results: { index: number; content: string | null }[] = [] for (let i = 0; i < pdfChunks.length; i += MAX_CONCURRENT_CHUNKS) { const batch = pdfChunks.slice(i, i + MAX_CONCURRENT_CHUNKS) - const batchPromises = batch.map((chunk, batchIndex) => - processChunk(chunk, i + batchIndex, pdfChunks.length, filename, apiKey, userId) - ) - - const batchResults = await Promise.all(batchPromises) - for (const result of batchResults) { - results.push(result) - } - - logger.info( - `Completed batch ${Math.floor(i / MAX_CONCURRENT_CHUNKS) + 1}/${Math.ceil(pdfChunks.length / MAX_CONCURRENT_CHUNKS)}` + const batchResults = await Promise.all( + batch.map((chunk, batchIndex) => { + const index = i + batchIndex + return recognize(chunk, index, pdfChunks.length).then( + (content) => ({ index, content }), + (error) => { + logger.warn('OCR chunk failed', { + provider, + chunk: index + 1, + error: toError(error).message, + }) + return { index, content: null } + } + ) + }) ) + results.push(...batchResults) } - const sortedResults = results + const recovered = results .sort((a, b) => a.index - b.index) - .filter((r) => r.content !== null) - .map((r) => r.content as string) - - if (sortedResults.length === 0) { + .map((r) => r.content) + .filter((content): content is string => content !== null && content.trim().length > 0) + + /** + * Each chunk has already exhausted its own retries, so a missing one is a real + * failure rather than a blip. Failing the document leaves it visible with a + * reason and eligible for the stuck-document sweep, which can retry it and + * produce a complete result — whereas indexing what came back would be + * indistinguishable from a document that never had those pages. + */ + if (recovered.length < pdfChunks.length) { throw new Error( - `OCR failed for all ${pdfChunks.length} chunks. ` + - `Large PDFs require OCR - file parser fallback would produce poor results.` + `OCR recovered ${recovered.length} of ${pdfChunks.length} chunks; ` + + 'indexing the document would omit the rest' ) } - const combinedContent = sortedResults.join('\n\n') - logger.info(`Successfully processed ${sortedResults.length}/${pdfChunks.length} chunks`) + return recovered.join('\n\n') +} - return { - content: combinedContent, - processingMethod: 'mistral-ocr', - cloudUrl, - } +async function processMistralOCRInBatches( + filename: string, + apiKey: string, + pdfBuffer: Buffer, + userId?: string, + cloudUrl?: string +): Promise<{ + content: string + processingMethod: 'mistral-ocr' + cloudUrl?: string +}> { + const content = await ocrPdfInChunks(pdfBuffer, 'mistral', (chunk, index, total) => + processChunk(chunk, index, total, filename, apiKey, userId).then((r) => r.content) + ) + + return { content, processingMethod: 'mistral-ocr', cloudUrl } } /** diff --git a/apps/sim/lib/knowledge/documents/pdf-ocr-triage.test.ts b/apps/sim/lib/knowledge/documents/pdf-ocr-triage.test.ts new file mode 100644 index 00000000000..48a29293d47 --- /dev/null +++ b/apps/sim/lib/knowledge/documents/pdf-ocr-triage.test.ts @@ -0,0 +1,272 @@ +/** + * @vitest-environment node + * + * Every PDF used to be sent to OCR, an external per-document call, even though the + * large majority carry a usable text layer that costs nothing to read. These pin + * the routing: the text layer is tried first, and OCR is reached only when it is + * missing or unreadable. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockParseBuffer, mockDownload, mockToken, mockBaseUrl } = vi.hoisted(() => ({ + mockParseBuffer: vi.fn(), + mockDownload: vi.fn(), + mockToken: vi.fn(), + mockBaseUrl: vi.fn(), +})) + +vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockToken })) +vi.mock('@/lib/core/utils/urls', async (importOriginal) => ({ + ...(await importOriginal()), + getInternalApiBaseUrl: mockBaseUrl, +})) + +vi.mock('@/lib/file-parsers', () => ({ + parseBuffer: mockParseBuffer, + isSupportedFileType: (extension: string) => ['pdf'].includes(extension), +})) +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ downloadFileFromUrl: mockDownload })) + +import { env } from '@/lib/core/config/env' +import { processDocument } from '@/lib/knowledge/documents/document-processor' +import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' + +/** External, so the OCR path uses the URL directly instead of re-uploading it. */ +const PDF_URL = 'https://example.com/Contract.pdf' +const typeset = 'The Supplier shall provide the Services described herein. '.repeat(60) + +/** A real PDF, because splitting loads the document rather than trusting metadata. */ +async function pdfOfPages(count: number): Promise { + const { PDFDocument } = await import('pdf-lib') + const pdf = await PDFDocument.create() + for (let i = 0; i < count; i++) pdf.addPage() + return Buffer.from(await pdf.save()) +} + +function parse() { + return runWithKnowledgeModelInputProvenance( + undefined, + () => processDocument(PDF_URL, 'Contract.pdf', 'application/pdf', 1024, 200, 1, 'user-1'), + { opaqueInputSafe: true } + ) +} + +describe('PDF OCR triage', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.assign(env, { OCR_PROVIDER: 'mistral', MISTRAL_API_KEY: 'key' }) + mockDownload.mockResolvedValue(Buffer.from('%PDF-1.7')) + mockToken.mockResolvedValue('internal-token') + mockBaseUrl.mockReturnValue('http://sim.local') + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses the embedded text layer and never calls OCR', async () => { + mockParseBuffer.mockResolvedValue({ content: typeset, metadata: {} }) + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const result = await parse() + + expect(result.metadata.processingMethod).toBe('file-parser') + expect(fetchMock).not.toHaveBeenCalled() + }) + + /** + * The density check reads its page count from the same parse as the text. A long + * scan that yields only a header must stay sparse against its real page count — + * counting separately allowed a failed count to present it as a single dense page. + */ + it('takes the page count from the parse, so a header-only scan stays sparse', async () => { + // Enough to clear the floor as a single page, nowhere near enough for 80. + const headerOnly = 'CONFIDENTIAL - Vendor Master Agreement - Page header. '.repeat(6) + mockParseBuffer.mockResolvedValue({ content: headerOnly, metadata: { pageCount: 80 } }) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ pages: [{ markdown: 'Recognised' }], usage_info: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await parse() + + expect(result.metadata.processingMethod).toBe('mistral-ocr') + }) + + it('falls through to OCR when the PDF is a scan', async () => { + mockParseBuffer.mockResolvedValue({ content: '', metadata: {} }) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ pages: [{ markdown: 'Recognised text' }], usage_info: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await parse() + + expect(result.metadata.processingMethod).toBe('mistral-ocr') + // 1001 pages against a 1000-page request cap: two chunks, two requests. + expect(fetchMock).toHaveBeenCalled() + }) + + /** + * The case a length check alone cannot see: a CID-keyed font with no Unicode map + * yields plenty of characters, none of them words. + */ + it('falls through to OCR when the text layer is raw CID escapes', async () => { + mockParseBuffer.mockResolvedValue({ content: '/31 /8 /18 /12 /44 '.repeat(60), metadata: {} }) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ pages: [{ markdown: 'Recognised' }], usage_info: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await parse() + + expect(result.metadata.processingMethod).toBe('mistral-ocr') + }) + + /** An encrypted or malformed PDF has no readable layer, which is a case for OCR. */ + it('falls through to OCR when the text layer cannot be parsed at all', async () => { + mockParseBuffer.mockRejectedValue(new Error('Invalid PDF structure.')) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ pages: [{ markdown: 'Recognised' }], usage_info: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await parse() + + expect(result.metadata.processingMethod).toBe('mistral-ocr') + }) +}) + +describe('Azure OCR chunking', () => { + /** + * Both providers cap how many pages one OCR request may carry. Mistral split the + * document to fit; Azure refused anything over the cap, so a long PDF could not + * be ingested at all. The cap belongs to a request, not to a document. + */ + it('splits a PDF past the page cap instead of refusing it', async () => { + Object.assign(env, { + OCR_PROVIDER: 'azure-mistral', + OCR_AZURE_API_KEY: 'key', + OCR_AZURE_ENDPOINT: 'https://example.openai.azure.com', + OCR_AZURE_MODEL_NAME: 'mistral-ocr', + }) + mockParseBuffer.mockResolvedValue({ content: '', metadata: { pageCount: 2500 } }) + mockDownload.mockResolvedValue(await pdfOfPages(1001)) + // A fresh Response per call: a body can only be read once. + const fetchMock = vi.fn().mockImplementation( + async () => + new Response(JSON.stringify({ pages: [{ markdown: 'Recognised page' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await parse() + + expect(result.metadata.processingMethod).toBe('mistral-ocr') + // 1001 pages against a 1000-page request cap: two chunks, two requests. + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + /** + * Splitting loads the document, which an encrypted or malformed PDF refuses. + * Those are precisely the files the triage sends here — no readable text layer — + * so a failed split must not decide whether they reach OCR at all. + */ + it('sends a PDF that cannot be split whole rather than refusing it', async () => { + Object.assign(env, { + OCR_PROVIDER: 'azure-mistral', + OCR_AZURE_API_KEY: 'key', + OCR_AZURE_ENDPOINT: 'https://example.openai.azure.com', + OCR_AZURE_MODEL_NAME: 'mistral-ocr', + }) + mockParseBuffer.mockRejectedValue(new Error('Invalid PDF structure.')) + mockDownload.mockResolvedValue(Buffer.from('not something pdf-lib can load')) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ pages: [{ markdown: 'Recognised' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await parse() + + expect(result.metadata.processingMethod).toBe('mistral-ocr') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + /** + * A response with no pages is no content. Returning the raw payload would index + * the API envelope as the document and satisfy the empty-content check meant to + * catch it. + */ + it('treats an Azure response carrying no pages as empty, not as content', async () => { + Object.assign(env, { + OCR_PROVIDER: 'azure-mistral', + OCR_AZURE_API_KEY: 'key', + OCR_AZURE_ENDPOINT: 'https://example.openai.azure.com', + OCR_AZURE_MODEL_NAME: 'mistral-ocr', + }) + mockParseBuffer.mockResolvedValue({ content: '', metadata: {} }) + mockDownload.mockResolvedValue(await pdfOfPages(2)) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ pages: [], usage_info: { pages_processed: 0 } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) + + // Counted as a failed chunk rather than stitched in as recovered text. + await expect(parse()).rejects.toThrow(/OCR recovered 0 of 1 chunks/) + }) + + /** + * A document is indexed whole or not at all. Returning the chunks that did come + * back would mark the document complete with whole page ranges missing from + * search, and nothing downstream could tell it apart from a complete one. + */ + it('fails the document when one chunk of several fails', async () => { + Object.assign(env, { + OCR_PROVIDER: 'azure-mistral', + OCR_AZURE_API_KEY: 'key', + OCR_AZURE_ENDPOINT: 'https://example.openai.azure.com', + OCR_AZURE_MODEL_NAME: 'mistral-ocr', + }) + mockParseBuffer.mockResolvedValue({ content: '', metadata: {} }) + mockDownload.mockResolvedValue(await pdfOfPages(1001)) + + let call = 0 + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(async () => { + call++ + if (call === 1) { + return new Response(JSON.stringify({ pages: [{ markdown: 'First half' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response('upstream failure', { status: 500 }) + }) + ) + + await expect(parse()).rejects.toThrow(/OCR recovered 1 of 2 chunks/) + }) +}) diff --git a/apps/sim/lib/knowledge/documents/pdf-text-layer.test.ts b/apps/sim/lib/knowledge/documents/pdf-text-layer.test.ts new file mode 100644 index 00000000000..9b759ee9b0c --- /dev/null +++ b/apps/sim/lib/knowledge/documents/pdf-text-layer.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { assessPdfTextLayer } from '@/lib/knowledge/documents/pdf-text-layer' + +/** Roughly the character volume of a typeset page. */ +const page = (n: number) => + 'The Supplier shall provide the Services described in this Statement of Work. '.repeat(n) + +describe('assessPdfTextLayer', () => { + it('accepts an ordinary typeset document', () => { + expect(assessPdfTextLayer(page(60), 2)).toEqual({ usable: true }) + }) + + /** + * A parser limit stops extraction partway, so the text that came back is plenty + * by volume but is only part of the document. Accepting it would index a + * fragment and drop the rest from search without saying so. + */ + it('rejects an extraction that stopped at a parser limit', () => { + expect(assessPdfTextLayer(page(200), 3, true)).toEqual({ usable: false, reason: 'truncated' }) + }) + + it('rejects a scan, which carries no text at all', () => { + expect(assessPdfTextLayer('', 12)).toEqual({ usable: false, reason: 'no-text' }) + expect(assessPdfTextLayer(' \n ', 12)).toEqual({ usable: false, reason: 'no-text' }) + }) + + /** A scan often still yields a header or a stamp — present, but not the content. */ + it('rejects text too sparse to be the document', () => { + expect(assessPdfTextLayer('CONFIDENTIAL', 40)).toEqual({ + usable: false, + reason: 'sparse-text', + }) + }) + + /** + * A CID-keyed font with no `ToUnicode` map extracts as raw character ids. There + * is plenty of it, so a length check passes and the document would be indexed as + * gibberish — the failure mode a characters-per-page test alone cannot see. + */ + it('rejects raw CID escapes from a font with no Unicode mapping', () => { + const cid = '/31 /8 /18 /12 /44 /9 /27 /15 /3 /62 '.repeat(40) + + expect(assessPdfTextLayer(cid, 1)).toEqual({ usable: false, reason: 'cid-escapes' }) + }) + + it('rejects a text layer that decoded to replacement characters', () => { + expect(assessPdfTextLayer('�'.repeat(500), 1)).toEqual({ + usable: false, + reason: 'unreadable-encoding', + }) + }) + + /** Real prose contains slashes and digits; only a dominant share is disqualifying. */ + it('keeps a document that merely mentions figures and dates', () => { + const prose = `${page(40)} Payment of /50 net 30, effective 01/04/2026, ref /12 /9.` + + expect(assessPdfTextLayer(prose, 1)).toEqual({ usable: true }) + }) + + it('keeps accented and non-Latin prose, which is ordinary text', () => { + expect(assessPdfTextLayer('Zusammenfassung über Verträge. '.repeat(40), 1)).toEqual({ + usable: true, + }) + expect(assessPdfTextLayer('契約の概要について説明します。'.repeat(40), 1)).toEqual({ + usable: true, + }) + }) + + /** An unparseable page count must still apply a floor rather than divide by zero. */ + it('treats an unknown page count as a single page', () => { + expect(assessPdfTextLayer('short', 0)).toEqual({ usable: false, reason: 'sparse-text' }) + expect(assessPdfTextLayer(page(40), 0)).toEqual({ usable: true }) + }) + + it('scales the threshold with length, so one good page does not carry a long scan', () => { + const onePageOfText = page(30) + + expect(assessPdfTextLayer(onePageOfText, 1)).toEqual({ usable: true }) + expect(assessPdfTextLayer(onePageOfText, 200)).toEqual({ + usable: false, + reason: 'sparse-text', + }) + }) +}) diff --git a/apps/sim/lib/knowledge/documents/pdf-text-layer.ts b/apps/sim/lib/knowledge/documents/pdf-text-layer.ts new file mode 100644 index 00000000000..ec7f42804fd --- /dev/null +++ b/apps/sim/lib/knowledge/documents/pdf-text-layer.ts @@ -0,0 +1,104 @@ +/** + * Minimum average characters per page for an embedded text layer to be trusted. + * + * A typeset page carries roughly 1,500–3,000 characters and a scanned image + * carries none, so this sits an order of magnitude below real prose and well above + * the handful of characters a scan contributes from a header or a stamp. The gap + * either side is wide enough that the exact value does not matter. + */ +const MIN_CHARS_PER_PAGE = 100 + +/** + * Share of characters that must be ordinary printable text. + * + * A text layer with a broken encoding extracts as mojibake or replacement + * characters: present in quantity, but not words. + */ +const MIN_PRINTABLE_RATIO = 0.8 + +/** + * Share of characters that may look like CID escapes before the layer is rejected. + * + * A CID-keyed font with no `ToUnicode` map extracts as the raw character ids — + * `/31 /8 /18 /12` — rather than glyphs. It passes a length check comfortably while + * containing no readable text at all. Common in documents from older generators and + * in anything using subset fonts, which is much of the contract and procurement + * material that reaches a knowledge base. + */ +const MAX_CID_ESCAPE_RATIO = 0.5 + +/** Runs of the form `/31 /8`, the raw output of a CID font with no Unicode map. */ +const CID_ESCAPE_PATTERN = /\/i?\d+/g + +/** Characters that count as ordinary text: printable ASCII, whitespace, and Latin-1+. */ +const PRINTABLE_PATTERN = /[\p{L}\p{N}\p{P}\p{Zs}\n\r\t]/gu + +export type PdfTextLayerVerdict = + | { usable: true } + | { + usable: false + reason: 'no-text' | 'truncated' | 'sparse-text' | 'unreadable-encoding' | 'cid-escapes' + } + +function countMatches(text: string, pattern: RegExp): number { + let total = 0 + for (const match of text.matchAll(pattern)) total += match[0].length + return total +} + +/** + * Judges whether a PDF's embedded text layer can be indexed as-is, or whether the + * document has to go through OCR to be readable. + * + * Extracting a text layer costs nothing and covers the large majority of PDFs; + * OCR is a per-document call to an external service. Asking this question first + * means only the documents that actually need OCR pay for it, and it removes the + * dependency on that service for everything else. + * + * Four ways a text layer fails, none caught by the others: there is no text (a + * scan), extraction stopped at a parser limit so what came back is only part of + * the document, the text is too sparse to be the document's real content, or + * there is plenty of text but it is not language — a broken encoding, or raw CID + * codes from a font with no Unicode mapping. + * + * Known limitation: this judges the document as a whole, so a file that mixes + * typeset pages with scanned inserts can average out above the threshold and keep + * its partial text. Routing per page would catch that, and needs per-page + * extraction this does not currently have. + */ +export function assessPdfTextLayer( + text: string, + pageCount: number, + truncated = false +): PdfTextLayerVerdict { + const trimmed = text.trim() + if (trimmed.length === 0) return { usable: false, reason: 'no-text' } + + /** + * Checked before anything measuring volume, because a truncated extraction has + * plenty of text by definition and would otherwise read as healthy. Accepting it + * would index part of a document and silently drop the rest from search, so the + * document goes to OCR, which reads it whole. + */ + if (truncated) return { usable: false, reason: 'truncated' } + + /** + * An unknown page count (a PDF whose header would not parse) is treated as a + * single page: it still applies a floor, without inventing a page count that + * would scale the threshold arbitrarily. + */ + const pages = pageCount > 0 ? pageCount : 1 + if (trimmed.length / pages < MIN_CHARS_PER_PAGE) return { usable: false, reason: 'sparse-text' } + + const cidChars = countMatches(trimmed, CID_ESCAPE_PATTERN) + if (cidChars / trimmed.length > MAX_CID_ESCAPE_RATIO) { + return { usable: false, reason: 'cid-escapes' } + } + + const printableChars = countMatches(trimmed, PRINTABLE_PATTERN) + if (printableChars / trimmed.length < MIN_PRINTABLE_RATIO) { + return { usable: false, reason: 'unreadable-encoding' } + } + + return { usable: true } +} From 681a8ec427badd236c43807d8f4bd32d865c4b01 Mon Sep 17 00:00:00 2001 From: andres Date: Wed, 19 Aug 2026 13:36:25 -0500 Subject: [PATCH 06/14] feat(resources): empty-state graphics for knowledge, tables, logs, and files (#6828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(resources): empty-state graphics for knowledge, tables, logs, files, skills Four of the resource pages (knowledge, tables, logs, files) had no empty state at all — `Resource.Table` painted column headers over a blank scroll area and stopped there. Skills had a `: null` branch for zero data. Adds a graphic per resource, drawn in the editor vignette's recipe: take the product's own primitives, shrink them, strip the content to skeletons, and let the composition bleed off the frame edges. - Knowledge — a document fanning into the chunks it is embedded as, using the editor's 6px smooth-step connector language in --workflow-edge - Tables — a sheet of cells running off two edges with one cell in an edit ring - Logs — runs stacked newest-first, their trace spans staggered into a waterfall - Files — a folder held open with one file still above its dashed landing slot - Skills — a skill card opened far enough to show the tools bundled inside `Resource.Table` gains a sanctioned `emptyState` slot rendered below the column headers when `rows` is empty, so the chrome guarantee still holds. Each page shows the graphic only for true zero-data — never for a search or filter that matched nothing, never inside an empty subfolder, and (logs) never before the first page of runs lands. Also ports the shared `EmptyState` frame from the editor branch so this branch stands alone, and adds a review-only /empty-states-preview gallery route. Co-Authored-By: Claude Opus 5 * improvement(tables): redraw the empty-state graphic in the house grayscale Matches the workflow editor's vignette and the landing feature graphics, which between them use no brand colour at all — every one of them is built from neutral tokens. Two corrections: - The blue edit ring is gone. Nothing in the reference graphics carries a hue, and it was the loudest element on the page. - `--surface-4`/`--surface-5` are near-white in light mode (#f5f5f5/#f3f3f3), so skeleton geometry built on them dissolved on a white card. Bars now mix `--text-secondary` into transparent at graded strengths — a real mid-grey that inverts with the theme, which is the idiom the editor vignette already uses for the one bar it needs you to see. Also drops the full-composition mask. The editor vignette keeps its block fully opaque and fades only the connector strokes leaving the frame; masking everything is what made the miniature read washed rather than deliberate. The card is crisp now and the continuation is drawn the way a real table draws it — an overflow fade at the edge the columns run off. Co-Authored-By: Claude Opus 5 * improvement(tables): strip the empty-state graphic to ruled lines and a corner fade Minimal pass. The card is gone — no border, no fill, no header shading, no type squares. What is left is the grid itself: hairline rules in `--border-1`, ink bars at two strengths, and the one cell held in an edit ring. With no card fill the grid sits directly on the page, so it can dissolve into the background instead of ending at a border. The fade is the landing page's own idiom — two gradients intersected (`mask-composite: intersect`), crisp at the top-left and gone through the bottom-right, the same construction `workflow-graph-preview` uses. Two placement notes: - The grid is offset right of frame centre. A diagonal dissolve puts the visual mass toward its opaque corner, so centring the geometry would leave the graphic reading left of the copy beneath it. - The selected cell sits in the quadrant the fade leaves fully opaque. A selection ring dissolving mid-stroke reads as a rendering fault, not a detail. Co-Authored-By: Claude Opus 5 * improvement(tables): make the selected cell opaque and a shade darker The ring mixed `--text-secondary` into `transparent`, so the grid rules running underneath showed through its own stroke. Mixing into `--bg` instead holds the same apparent value while staying opaque, and still inverts with the theme. Raised 32% -> 46% so it reads as chrome rather than more content, and added a stacking context: neighbouring cells are later siblings, so their rules were painting over the ring's right and bottom edges. Co-Authored-By: Claude Opus 5 * improvement(tables): round the empty-state grid's crisp corner 6px on the top-left only — the one corner the fade leaves intact, and the same radius the workflow editor's vignette uses. The other three dissolve, so there is nothing there to round. Co-Authored-By: Claude Opus 5 * feat(knowledge,tables): redraw knowledge's empty state and add docs/create chips Knowledge gets the same treatment tables just went through: no brand colour (the `--brand-knowledge` accent is gone), no card chrome, ink mixed from `--text-secondary`, and the landing page's intersected corner fade. The graphic is a document and the chunks it is embedded as. Its fade is held back further than the tables grid on both axes — the document has to stay whole for the graphic to mean anything, so only the chunk grid may trail off. The three chunks the edges actually land on are the only filled ones; filling the whole first column left a chunk with no edge feeding it. Both empty states now carry two chips in the frame's action slot — a docs link and the create action, each running the same handler as the header's primary chip and inheriting its disabled state. Co-Authored-By: Claude Opus 5 * review(knowledge): three candidate depictions, and lead with the create chip Chip order swapped on both empty states — the primary action reads first, the docs link second. Adds a review-only `knowledge-alternates.tsx` rendered in the preview gallery, because the document-to-chunks graphic is not landing. Three directions: - A. the embedding mesh — the landing hero's own knowledge-base panel already draws a base this way (`stage-kb.tsx`), so this is the house depiction rather than a new invention - B. a stack of documents — the most literal reading, at the cost of colliding with what the files empty state wants to draw - C. a query and the passages that answered it — depicts what a base is for, which is what the description copy actually promises Delete this file and the gallery entries once one is chosen. Co-Authored-By: Claude Opus 5 * feat(knowledge): draw the empty state as an isometric set of volumes Replaces the document-to-chunks diagram, which read as a workflow graph rather than as a knowledge base. Built on the landing page's iso-illustration recipe rather than a new one: `ISO_STROKE` contours (`--text-subtle` mixed toward `--text-muted`) at the shared 3.2 stroke width, faces filled from the three-tier surface ramp brightest-on-top, round caps and joins. Geometry is authored in a large unit space so that 3.2 lands as a hairline once scaled to empty-state size — the same reason the landing marks draw 3.2 into a ~526-unit viewBox. The projection and faces are computed rather than hand-authored as path data, so the volumes stay coherent when the geometry is retuned. No corner fade here. The fade belongs to repeating structures that mean the same thing cropped — the tables grid keeps its meaning with two columns or four. A discrete object does not, which is also why the workflow editor's vignette keeps its block fully opaque. Drops the three candidate depictions now that the direction is settled. Co-Authored-By: Claude Opus 5 * improvement(knowledge): brand the front volume instead of laying a page beside it Drops the loose page on the ground and puts the knowledge-base mark on the front volume's cover — the same `Database` glyph the sidebar and the page header use, so the empty state names its own resource. The mark is laid into the cover's plane rather than drawn over it. The cover is the face at max x, spanned by the volume's depth across and its height up; walking those two edges gives the face's basis vectors in projected space, and an affine matrix built from them maps flat artwork into the face. So the glyph skews with the isometric, and because both vectors derive from the box, retuning the volumes carries the mark with them instead of stranding hand-fitted path data. Its stroke is pre-divided by the same factor the matrix scales by, so the glyph's contours land at the volumes' weight rather than four times it. Co-Authored-By: Claude Opus 5 * improvement(knowledge): bore through the front volume, and fade the set back Replaces the mark on the cover with a hole through it. The bore is authored as a plain circle in the cover's own plane and skewed into an ellipse by the face matrix. Its far mouth is the same circle stepped back through the volume: boring straight back is a world step of `-w` along x, and solving the cover-plane matrix for the local offset that produces it gives `(+w, -w)`. The sliver of near mouth the far mouth fails to cover is exactly the wall you see down the hole, so the depth falls out of the geometry rather than being drawn by hand. Down the hole the near mouth is floored in a tone darker than any outer face — the wall turns away from the light — and the far mouth is painted in the cover tone of the volume standing behind it, because looking through a hole in the front volume lands on that volume's face, not on the page. Corners stay square. Rounding was tried and reverted: rounding each face separately notches every corner where three faces meet, and rounding the silhouette instead cost a clip per volume for a softness the set did not want. The tables grid's corner fade is applied along the other diagonal. There it dissolves toward the bottom-right because a grid keeps its meaning cropped; here the set recedes up and to the left and the front volume carries the bore, so anchoring at the bottom-right eats into the back of the stack and reads as more volumes behind. Co-Authored-By: Claude Opus 5 * feat(resources): logs and files graphics, and settle the set as one collection Logs is an activity feed — newest run lifted onto its own card, older ones settling behind it. The relative stamps are the only literal text in any of these graphics; everything else stays skeleton, so nothing here has to be translated or kept true. Files is a folder with sheets standing proud of its front panel. Depth comes from the surface ramp rather than shadow, which would need separate light and dark recipes where the ramp inverts on its own. The tab's diagonal is filleted at both ends and every outer corner shares one radius — mixing radii, or running the diagonal into square junctions, made the corners fight at this size. Consistency pass across the set: - Titles are the resource name alone. "No tables yet" earned nothing the description does not already say. - The knowledge mark is mirrored so its bore faces left. Rebuilt on the geometry rather than flipped, since a flip would have put the shading on the wrong side. Its contours are thinned and mixed toward `--border-1`: the landing marks are the focal art of their section, but this one sits beside a ruled grid whose lines are 1px, and full-weight contours read as ink next to it. - The logs feed is sized to the same ~148px footprint as the rest. The frame centres graphic and copy together, so a taller graphic pushes its title out of line with the others' and the set stops reading as one thing. - Every empty state carries its create action and a docs link, each running the same handler as the header's primary chip. - Fades run whichever way the subject recedes: the tables grid to the bottom-right, the knowledge set up and right, the logs feed down, the folder up. Fixes a duplicate React key in the knowledge mark — the volumes stack along y now, so keying on `box.x` gave every one of them `0`. Co-Authored-By: Claude Opus 5 * revert(skills): drop the skills empty state Removed at request. The skills list goes back to rendering nothing for zero data, which is what it did before this branch. Takes `vignette.tsx` with it — the shared stage and skeleton bar were left over from the first pass, and skills was the last thing still importing them once the other four graphics were redrawn. Co-Authored-By: Claude Opus 5 * cleanup(resources): fix empty-state flashes and share the iso ramp Drops the review-only preview route and gallery, which the branch always meant to delete before merging. Three ways the zero-data graphic painted over a workspace that has content: - The gate read the instant URL search term while `rows` is filtered by the debounced one, so clearing a search that matched nothing showed the full "you have nothing yet" state for one debounce window. - Nothing gated on the list still loading. Knowledge and tables hydrate from a server prefetch that is allowed to seed nothing, and the files list deliberately seeds nothing above 300 rows — so the emptiest-looking screen was shown to the fullest workspaces. - The filters are part of the query key and every list keeps the previous key's data, so `isLoading` is false across a filter change. Only the placeholder gate suppresses the graphic during that refetch. Also folds the re-declared isometric fills and stroke back onto the shared `iso-illustration-style` source they were copied from, so a change to the iso ramp reaches this mark too; only the stroke width still diverges. The static face paths move to module scope, the bore interior becomes a named component so its note is TSDoc rather than a JSX comment, and the four identical docs chips become one. * simplify(resources): reuse the iso recipe and let the frame own its layout Hides the empty-state graphic behind `error` as well. A failed load also leaves `rows` empty, and inviting someone to create their first item is the wrong answer to a request that did not complete — all four pages only logged the error, so the zero-data copy was what a failed load actually rendered. `iso-illustration-style` moves out of the landing route group to `components/iso/`. Importing it from a workspace route was the only workspace-to-landing edge in the app, one directory away from an `iso-marks` barrel that pulls ~10KB gzipped of illustration components — a hazard for whoever needs the second constant. The contour recipe is now shared too: `createIsoLineProps` takes an optional stroke width, so the knowledge mark stops re-declaring it and only its weight diverges. `EmptyState` owns the action row's layout, so the three pages with two chips drop their wrapper div and every empty state's chips sit identically. Its unused `className` prop goes with them. Also drops the `height` prop that had one caller passing its default, and the `CORNER` constant that promised single-sourcing the path's four bare literals did not honour. * simplify(resources): decide list emptiness in one place "This list holds nothing" was derived in four pages, each with the same seven clauses under the same nine-line comment. Adding the `error` gate one commit ago took four identical edits, and the skills empty state that was reverted off this branch would have made it five copies. `isResourceListEmpty` now owns the rule and the reasoning behind each gate. Logs omits the folder argument because it has no folder navigation; the other three pass theirs. `Resource.Table` also wraps the slot in its own growth box, so the empty state centres because the table says so rather than because the node handed to it happened to carry `flex-1`. * chore(audits): re-record the page module-graph baseline The four empty-state graphics and the shared frame add **+10 modules** to each of the five routes that render them — measured against `origin/staging`, not against the recorded baseline: files/[fileId] 1958 -> 1968 files 1958 -> 1968 knowledge 2167 -> 2177 logs 1727 -> 1737 tables 1817 -> 1827 The baseline itself was last recorded in #6697, and staging has drifted up to +29 on tables since — inside the max(25, 2%) tolerance on its own, but close enough that this +10 tipped it over. So the failure was the stale baseline meeting a small real addition, not a heavy import. The other 29 entries move only by that accumulated drift. The graphics stay eagerly imported on purpose: an empty state is the first thing a new workspace paints, and deferring ~4KB gzipped behind a chunk request would trade a shared, already-fetched module for a visible pop on the one screen where the product has to look like it works. * fix(resources): hold the empty state until folders resolve Folder rows share the list with resource rows, so a workspace whose only contents are folders has an empty `rows` until the folder tree lands — and got the "create your first item" graphic in the gap. The resource list's own loading gates never covered it because the folder tree is a separate query. `useFolderNavigation` already exposes `foldersResolved` (`isSuccess && !isPlaceholderData`) for exactly this hazard — it guards the ancestry index against evicting a folder id it has not loaded yet. Knowledge and tables pass it straight through; files reads the same two flags off `useWorkspaceFileFolders`, which it calls directly. Logs omits it, as it has no folders. --------- Co-authored-by: andresdjasso Co-authored-by: Claude Opus 5 Co-authored-by: Waleed Latif --- .../iso-marks/iso-build-illustration.tsx | 2 +- .../iso-marks/iso-ingest-illustration.tsx | 2 +- .../iso-marks/iso-integrate-illustration.tsx | 2 +- .../iso-marks/iso-monitor-illustration.tsx | 2 +- .../resource-empty-state/docs-link.tsx | 21 + .../files-empty-state.tsx | 103 +++++ .../components/resource-empty-state/index.ts | 4 + .../knowledge-empty-state.tsx | 36 ++ .../resource-empty-state/knowledge-iso.tsx | 231 ++++++++++ .../resource-empty-state/logs-empty-state.tsx | 76 ++++ .../tables-empty-state.tsx | 128 ++++++ .../resource/is-resource-list-empty.ts | 65 +++ .../components/resource/resource.tsx | 19 +- .../workspace/[workspaceId]/files/files.tsx | 36 +- .../[workspaceId]/knowledge/knowledge.tsx | 20 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 13 + .../workspace/[workspaceId]/tables/tables.tsx | 28 +- .../components/empty-state/empty-state.tsx | 27 ++ .../iso}/iso-illustration-style.ts | 14 +- ...check-tool-registry-boundary.baseline.json | 420 +++++++++--------- 20 files changed, 1027 insertions(+), 222 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.ts create mode 100644 apps/sim/components/empty-state/empty-state.tsx rename apps/sim/{app/(landing)/components/mothership/components/iso-marks => components/iso}/iso-illustration-style.ts (68%) diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx index 296f0a01318..00f921aaca9 100644 --- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx +++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx @@ -5,7 +5,7 @@ import { ISO_FILL_LOW, ISO_FILL_MID, ISO_STROKE, -} from '@/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style' +} from '@/components/iso/iso-illustration-style' export interface IsoBuildIllustrationProps { size?: number diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx index 9411b4b3fa9..980bb792339 100644 --- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx +++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx @@ -9,7 +9,7 @@ import { ISO_FILL_PULSE_LOW, ISO_FILL_PULSE_MID, ISO_STROKE, -} from '@/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style' +} from '@/components/iso/iso-illustration-style' export interface IsoIngestIllustrationProps { size?: number diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx index 955fca0f61e..4981feb35b0 100644 --- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx +++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx @@ -5,7 +5,7 @@ import { ISO_FILL_LOW, ISO_FILL_MID, ISO_STROKE, -} from '@/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style' +} from '@/components/iso/iso-illustration-style' export interface IsoIntegrateIllustrationProps { size?: number diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx index dd7643d14d3..b70bab7a7b1 100644 --- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx +++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx @@ -5,7 +5,7 @@ import { ISO_FILL_LOW, ISO_FILL_MID, ISO_STROKE, -} from '@/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style' +} from '@/components/iso/iso-illustration-style' export interface IsoMonitorIllustrationProps { size?: number diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link.tsx new file mode 100644 index 00000000000..f0d47606e7f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link.tsx @@ -0,0 +1,21 @@ +import { ChipLink } from '@sim/emcn' +import { BookOpen } from '@sim/emcn/icons' + +interface EmptyStateDocsLinkProps { + href: string +} + +/** The docs chip every resource empty state carries, so the four stay identical. */ +export function EmptyStateDocsLink({ href }: EmptyStateDocsLinkProps) { + return ( + + Docs + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx new file mode 100644 index 00000000000..09a5f802c6c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx @@ -0,0 +1,103 @@ +import { Chip, cn } from '@sim/emcn' +import { Upload } from '@sim/emcn/icons' +import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' + +const FILES_DOCS_URL = 'https://docs.sim.ai/files' + +/** + * Hairline contours, matching the tables grid's 1px `--border-1` rules and the + * knowledge mark's thinned strokes — the graphics sit one nav item apart, so a + * heavier outline here would read as a different illustration system. + * + * Fills stay near the top of the surface ramp for the same reason: the border + * draws the folder and the fill only has to separate one layer from the next. A + * solid mid-grey body made this the heaviest thing on the page. + */ +const HAIRLINE = { + stroke: 'var(--border-1)', + strokeWidth: 1.1, + strokeLinejoin: 'round' as const, +} as const + +const FOLDER_BACK = [ + 'M 22 34', + 'H 66', + 'Q 72 34 75.5 38', + 'L 82.5 46', + 'Q 86 50 92 50', + 'H 178', + 'Q 188 50 188 60', + 'V 140', + 'Q 188 150 178 150', + 'H 22', + 'Q 12 150 12 140', + 'V 44', + 'Q 12 34 22 34', + 'Z', +].join(' ') + +/** + * Dissolves upward, so the folder rises out of the page while its front panel + * stays crisp where the copy begins. Running it the other way — the direction the + * logs feed fades — ate the panel's base and left the tab hanging. + */ +const FOLDER_FADE = + '[-webkit-mask-image:linear-gradient(to_top,#000_58%,transparent_100%)] [mask-image:linear-gradient(to_top,#000_58%,transparent_100%)]' + +/** + * A folder held open with sheets standing proud of its front panel. + * + * Depth is carried by the surface ramp rather than by shadow: the back panel is the + * darkest tier, the sheets the lightest, the front panel between them. Shadows + * would need separate light and dark recipes; the ramp inverts on its own. + * + * Every outer corner shares the same 10-unit radius so the silhouette reads as a + * single drawn shape — mixing radii makes the corners fight each other at this size. + */ +function FilesGraphic() { + return ( + + ) +} + +interface FilesEmptyStateProps { + /** Opens the file picker — the same action the header's upload chip runs. */ + onUpload: () => void + /** Mirrors the header chip's disabled state: no edit rights, or an upload in flight. */ + uploadDisabled?: boolean +} + +/** Empty state for the files list when the workspace has none. */ +export function FilesEmptyState({ onUpload, uploadDisabled = false }: FilesEmptyStateProps) { + return ( + } + title='Files' + description='Upload files to share them across your team and every agent.' + action={ + <> + + Upload + + + + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts new file mode 100644 index 00000000000..7b34c26e6b5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts @@ -0,0 +1,4 @@ +export { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state' +export { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state' +export { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state' +export { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx new file mode 100644 index 00000000000..dcfdc7a3664 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx @@ -0,0 +1,36 @@ +import { Chip } from '@sim/emcn' +import { Plus } from '@sim/emcn/icons' +import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' +import { KnowledgeIsoMark } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso' + +const KNOWLEDGE_DOCS_URL = 'https://docs.sim.ai/knowledgebase' + +interface KnowledgeEmptyStateProps { + /** Opens the create-base modal — the same action the header's primary chip runs. */ + onCreate: () => void + /** Mirrors the header chip's disabled state: no edit rights on the workspace. */ + createDisabled?: boolean +} + +/** Empty state for the knowledge bases list when the workspace has none. */ +export function KnowledgeEmptyState({ + onCreate, + createDisabled = false, +}: KnowledgeEmptyStateProps) { + return ( + } + title='Knowledge bases' + description='Upload documents to give your agents a memory they can search.' + action={ + <> + + New base + + + + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso.tsx new file mode 100644 index 00000000000..37cd2b50a9f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso.tsx @@ -0,0 +1,231 @@ +import { cn } from '@sim/emcn' +import { + createIsoLineProps, + ISO_FILL_HIGH, + ISO_FILL_LOW, + ISO_FILL_MID, + ISO_FILL_PULSE_LOW, + ISO_STROKE as ISO_STROKE_BASE, +} from '@/components/iso/iso-illustration-style' + +const COS_30 = Math.cos(Math.PI / 6) + +type Point = readonly [number, number] + +/** Standard isometric projection: +x right-and-down, +y left-and-down, +z up. */ +function project(x: number, y: number, z: number): Point { + return [(x - y) * COS_30, (x + y) * 0.5 - z] +} + +function toPath(points: Point[]): string { + return `${points.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`).join(' ')} Z` +} + +interface Box { + x: number + y: number + z: number + w: number + d: number + h: number +} + +/** The three faces an isometric viewer can see, brightest on top. */ +function boxFaces(box: Box) { + const { x, y, z, w, d, h } = box + const x1 = x + w + const y1 = y + d + const z1 = z + h + return { + top: [project(x, y, z1), project(x1, y, z1), project(x1, y1, z1), project(x, y1, z1)], + right: [project(x1, y, z1), project(x1, y1, z1), project(x1, y1, z), project(x1, y, z)], + left: [project(x, y1, z1), project(x1, y1, z1), project(x1, y1, z), project(x, y1, z)], + } +} + +/** + * Thin along y rather than x, so the cover is the face at max y — the one the + * projection turns to the left. Stacking along y then walks the set left-and-down + * toward the viewer, so drawing in offset order paints back to front. + */ +const SLABS: Box[] = [0, 90, 180].map((offset) => ({ + x: 0, + y: offset, + z: 0, + w: 208, + d: 46, + h: 236, +})) + +/** + * Lighter and thinner than the landing marks draw them. + * + * Those marks are the focal art of their section; here the mark sits beside a + * ruled grid and a skeleton feed whose lines are 1px of `--border-1`. Carrying + * the landing's full-weight contour made the volumes read as ink next to those, + * so the shared stroke is mixed toward `--border-1` and thinned to land near a + * hairline once the mark is scaled to empty-state size. Only the width diverges + * from the shared recipe; the fills are imported so a change to the iso ramp + * reaches this mark too. + */ +const ISO_STROKE = `color-mix(in srgb, ${ISO_STROKE_BASE} 55%, var(--border-1))` +/** Darker than any outer face — the bore's wall turns away from the light. */ +const ISO_FILL_BORE = ISO_FILL_PULSE_LOW +const KNOWLEDGE_STROKE_WIDTH = 1.9 + +/** Matches the other three resource graphics, so the set centres as one collection. */ +const MARK_HEIGHT = 148 + +/** + * Maps flat artwork into the plane of the front volume's cover. + * + * The cover is the face at max y, spanned by the volume's width going across and + * its height going up. Those two edges are its basis vectors in projected space, + * and a matrix built from them lets a plain `` be authored in face + * coordinates — the projection skews it into the right ellipse. Local units are + * world units measured on the face, so a circle stays circular *on the cover* + * instead of being stretched by the face's aspect. + */ +const COVER = SLABS[SLABS.length - 1] + +const COVER_PLANE = (() => { + const [originX, originY] = project(COVER.x, COVER.y + COVER.d, COVER.z) + return `matrix(${COS_30.toFixed(4)} 0.5 0 1 ${originX.toFixed(3)} ${(originY - COVER.h).toFixed(3)})` +})() + +const BORE_RADIUS = 62 +const BORE_CX = COVER.w / 2 +const BORE_CY = COVER.h / 2 + +/** + * The far mouth of the bore, in the same cover-plane coordinates. + * + * Boring straight back through the volume is a world-space step of `-d` along y. + * Solving the cover-plane matrix for the local offset that produces that step + * gives `(+d, -d)` — so the far mouth sits up and left of the near one by exactly + * the volume's thickness, and the sliver of near-mouth it fails to cover is the + * wall you see down the hole. + */ +const FAR_CX = BORE_CX + COVER.d +const FAR_CY = BORE_CY - COVER.d + +const BORE_MASK_ID = 'knowledge-iso-bore-mask' +const BORE_CLIP_ID = 'knowledge-iso-bore-clip' + +const ALL_POINTS: Point[] = SLABS.flatMap((box) => Object.values(boxFaces(box)).flat()) + +/** `SLABS` is a module constant and the projection is pure, so every face path is fixed. */ +const SLAB_PATHS = SLABS.map((box) => { + const faces = boxFaces(box) + return { + key: `${box.x}-${box.y}`, + left: toPath(faces.left), + right: toPath(faces.right), + top: toPath(faces.top), + } +}) + +const PADDING = 14 +const MIN_X = Math.min(...ALL_POINTS.map(([x]) => x)) - PADDING +const MAX_X = Math.max(...ALL_POINTS.map(([x]) => x)) + PADDING +const MIN_Y = Math.min(...ALL_POINTS.map(([, y]) => y)) - PADDING +const MAX_Y = Math.max(...ALL_POINTS.map(([, y]) => y)) + PADDING +const VIEW_BOX = `${MIN_X.toFixed(2)} ${MIN_Y.toFixed(2)} ${(MAX_X - MIN_X).toFixed(2)} ${(MAX_Y - MIN_Y).toFixed(2)}` + +/** + * The tables grid's corner fade, run along the other diagonal. + * + * There it dissolves toward the bottom-right, because a grid keeps its meaning + * cropped. Here the set recedes up and to the right, and the front volume carries + * the bore — so the fade is anchored at the bottom-left and eats into the back of + * the set instead, reading as more volumes behind rather than dissolving the one + * detail worth looking at. + */ +const STACK_FADE = + '[-webkit-mask-image:linear-gradient(to_right,#000_56%,transparent_100%),linear-gradient(to_bottom,transparent_0%,#000_40%)] [mask-image:linear-gradient(to_right,#000_56%,transparent_100%),linear-gradient(to_bottom,transparent_0%,#000_40%)] [-webkit-mask-composite:source-in] [mask-composite:intersect]' + +/** Shared iso contour recipe at this mark's own weight; spread onto both paths and circles. */ +const LINE_PROPS = createIsoLineProps( + undefined, + ISO_STROKE, + KNOWLEDGE_STROKE_WIDTH +) + +/** + * Down the hole: the near mouth is floored with the wall tone, then the far mouth + * is painted over it in the cover tone of the volume standing behind — looking + * through a bore in the front volume lands on that volume's face, not on the page. + * Both are clipped to the near mouth so the bore never paints outside its opening. + */ +function BoreInterior() { + return ( + + + + + + ) +} + +/** + * Isometric knowledge-base mark on the landing page's iso-illustration recipe. + * + * Geometry is authored in a large unit space so the shared stroke constant lands + * as a hairline once the mark is scaled to empty-state size — as it does on the + * landing marks, which draw 3.2 into a ~526-unit viewBox. + */ +export function KnowledgeIsoMark() { + const width = MARK_HEIGHT * ((MAX_X - MIN_X) / (MAX_Y - MIN_Y)) + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx new file mode 100644 index 00000000000..6ea0d5ba336 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx @@ -0,0 +1,76 @@ +import { cn } from '@sim/emcn' +import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' + +const LOGS_DOCS_URL = 'https://docs.sim.ai/logs-debugging' + +/** Skeleton ink — see the `INK` note in `tables-empty-state.tsx` for why not the surface ramp. */ +const INK = { + title: 'color-mix(in srgb, var(--text-secondary) 32%, transparent)', + detail: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)', +} as const + +interface ActivityRow { + stamp: string + title: number + detail: number +} + +const ROWS: ActivityRow[] = [ + { stamp: 'Now', title: 72, detail: 112 }, + { stamp: '12 min ago', title: 62, detail: 124 }, + { stamp: '1h ago', title: 76, detail: 100 }, + { stamp: 'Jul 8', title: 56, detail: 108 }, +] + +/** + * Vertical falloff so the feed dissolves into the page instead of ending on a hard + * last row — the list is a repeating structure, so cropping it costs nothing. + */ +const FEED_FADE = + '[-webkit-mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)] [mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)]' + +/** Four rows, sized to the ~148px the other resource graphics occupy so the frame centres the set alike. */ +function LogsGraphic() { + return ( + + ) +} + +/** Empty state for the logs list when the workspace has no runs yet. */ +export function LogsEmptyState() { + return ( + } + title='Logs' + description='Every workflow execution lands here, traced block by block.' + action={} + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx new file mode 100644 index 00000000000..5ca521d2e99 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx @@ -0,0 +1,128 @@ +import { Chip, cn } from '@sim/emcn' +import { Plus } from '@sim/emcn/icons' +import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' + +/** + * Neutral ink at two strengths. + * + * `--surface-4`/`--surface-5` are near-white in light mode (#f5f5f5/#f3f3f3), so + * skeleton geometry built on them dissolves against the page. Mixing + * `--text-secondary` into transparent gives a real mid-grey that inverts with the + * theme — the idiom the workflow editor's vignette uses for the one bar it needs + * you to actually see. + */ +const INK = { + header: 'color-mix(in srgb, var(--text-secondary) 30%, transparent)', + cell: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)', + /** + * Mixed into `--bg` rather than `transparent`: a translucent ring lets the + * grid rules underneath show through its own stroke. Opaque, and darker than + * the ink it surrounds so it reads as chrome rather than more content. + */ + selection: 'color-mix(in srgb, var(--text-secondary) 46%, var(--bg))', +} as const + +/** + * Crisp at the top-left, dissolving through the bottom-right — the same + * two-gradient intersect the landing page's workflow vignette uses, so the grid + * blends into the page rather than sitting on it as a card. + */ +const CORNER_FADE = + '[-webkit-mask-image:linear-gradient(to_right,#000_62%,transparent_100%),linear-gradient(to_bottom,#000_56%,transparent_100%)] [mask-image:linear-gradient(to_right,#000_62%,transparent_100%),linear-gradient(to_bottom,#000_56%,transparent_100%)] [-webkit-mask-composite:source-in] [mask-composite:intersect]' + +const COLUMN_TEMPLATE = '88px 72px 72px 72px' + +const HEADER_WIDTHS = [36, 28, 26, 28] as const + +const CELL_WIDTHS = [ + [54, 34, 26, 38], + [44, 30, 34, 30], + [60, 38, 22, 40], + [40, 26, 30, 34], + [50, 32, 28, 36], +] as const + +/** + * The one cell held in an edit ring, placed in the top-left quadrant the corner + * fade leaves fully opaque — a selection dissolving mid-stroke would read as a + * rendering fault rather than a detail. + */ +const SELECTED_CELL = { row: 1, column: 0 } as const + +/** Skeleton grid with one cell held in an edit ring, running off two edges. */ +function TablesGraphic() { + return ( + + ) +} + +const TABLES_DOCS_URL = 'https://docs.sim.ai/tables' + +interface TablesEmptyStateProps { + /** Creates a table — the same action the header's primary chip runs. */ + onCreate: () => void + /** Mirrors the header chip's disabled state: no edit rights, or a create already in flight. */ + createDisabled?: boolean +} + +/** Empty state for the tables list when the workspace has none. */ +export function TablesEmptyState({ onCreate, createDisabled = false }: TablesEmptyStateProps) { + return ( + } + title='Tables' + description='Create a table to store structured data your agents can read and write.' + action={ + <> + + New table + + + + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.ts new file mode 100644 index 00000000000..c9b8ece3273 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.ts @@ -0,0 +1,65 @@ +interface ResourceListEmptyInput { + rowCount: number + /** The list query's first load. No rows yet means "not arrived", not "none exist". */ + isLoading: boolean + /** The query is serving the previous key's rows while the new key refetches. */ + isPlaceholderData: boolean + /** A failed load also leaves the list empty. */ + error: unknown + /** + * The search term the rows are actually filtered by — the debounced value, never the + * instant URL one. + */ + search: string + filterCount: number + /** The open folder, or `null` at the root. Omit for lists without folder navigation. */ + folderId?: string | null + /** + * Whether the folder tree has resolved. Folder rows share the list with resource rows, + * so a workspace holding only folders looks empty until they arrive. Omit for lists + * without folder navigation. + */ + foldersResolved?: boolean +} + +/** + * Whether a resource list holds nothing, as opposed to merely showing nothing. + * + * A zero-data graphic invites someone to create their first item, so it may only + * appear when that is the true answer. Every other way `rows` empties out is a + * different message and gets gated here: + * + * - A search or filter that matched nothing, or an empty subfolder — the copy would + * be wrong. The search must be the debounced value the rows are filtered by; + * reading the instant URL value flashes the graphic for one debounce window after + * a search is cleared. + * - The list still arriving. Server prefetches are allowed to seed nothing, and the + * files list deliberately seeds nothing above 300 rows — so without this the + * emptiest-looking screen is shown to the fullest workspaces. + * - The query serving the previous key's rows. Filters are part of the query key and + * every list keeps previous data, so `isLoading` is false across a filter change. + * - A failed load, which is not an invitation to create anything. + * - The folder tree still resolving, for the same reason as the rows themselves: a + * workspace whose only contents are folders reads as empty until they land. + */ +export function isResourceListEmpty({ + rowCount, + isLoading, + isPlaceholderData, + error, + search, + filterCount, + folderId = null, + foldersResolved = true, +}: ResourceListEmptyInput): boolean { + return ( + rowCount === 0 && + !isLoading && + !isPlaceholderData && + !error && + foldersResolved && + folderId === null && + !search.trim() && + filterCount === 0 + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 31ca47ae1bc..0742f9a93cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -225,6 +225,16 @@ interface ResourceTableProps { * chrome and positioning; it never alters the table's rendering. */ overlay?: ReactNode + /** + * Sanctioned empty slot. Rendered below the column headers when `rows` is + * empty, filling the otherwise blank scroll area. It never replaces the table + * region or the headers — the chrome guarantee holds — so a consumer can show + * a zero-data graphic without the list losing its structure. + * + * The table owns the growth box the slot is centred in, so any node centres — + * the slot does not have to carry its own `flex-1` to sit in the middle. + */ + emptyState?: ReactNode } /** @@ -263,10 +273,13 @@ const ResourceTable = memo(function ResourceTable({ isLoadingMore, pagination, overlay, + emptyState, }: ResourceTableProps) { const scrollRef = useRef(null) const loadMoreRef = useRef(null) + const showEmptyState = rows.length === 0 && Boolean(emptyState) + const [contextMenuRowId, setContextMenuRowId] = useState(null) const wrappedOnRowContextMenu = useCallback( @@ -358,7 +371,10 @@ const ResourceTable = memo(function ResourceTable({
+ {showEmptyState ?
{emptyState}
: null} {hasMore && (
{isLoadingMore && ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index f2fce25c67a..b655e6f2d25 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -85,6 +85,8 @@ import { useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' +import { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' +import { isResourceListEmpty } from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty' import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal' import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' @@ -263,8 +265,19 @@ export function Files() { } }, [permissionConfig.hideFilesTab, router, workspaceId]) - const { data: files = EMPTY_WORKSPACE_FILES, isLoading, error } = useWorkspaceFiles(workspaceId) - const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useWorkspaceFileFolders(workspaceId) + const { + data: files = EMPTY_WORKSPACE_FILES, + isLoading, + isPlaceholderData, + error, + } = useWorkspaceFiles(workspaceId) + const { + data: folders = EMPTY_WORKSPACE_FILE_FOLDERS, + isSuccess: foldersLoaded, + isPlaceholderData: foldersArePlaceholder, + } = useWorkspaceFileFolders(workspaceId) + /** Matches `FolderNavigation.foldersResolved`, which the other resource pages read. */ + const foldersResolved = foldersLoaded && !foldersArePlaceholder const { data: members } = useWorkspaceMembersQuery(workspaceId) const pinnedFileIds = usePinnedIds(workspaceId, 'file') // Folders pin under their own resource type, so their pinned set is a separate query. @@ -1804,6 +1817,17 @@ export function Files() { return tags }, [typeFilter, sizeFilter, uploadedByFilter, membersById]) + const showEmptyState = isResourceListEmpty({ + rowCount: rows.length, + isLoading, + isPlaceholderData, + error, + search: debouncedSearchTerm, + filterCount: filterTags.length, + folderId: currentFolderId, + foldersResolved, + }) + if (fileIdFromRoute && !selectedFile && isLoading) { return ( @@ -1904,6 +1928,14 @@ export function Files() { filter={filterConfig} /> + ) : undefined + } columns={COLUMNS} rows={rows} selectable={selectableConfig} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 207f8b9599e..32fbed78a31 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -54,6 +54,8 @@ import { useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' +import { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' +import { isResourceListEmpty } from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty' import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { CreateBaseModal, @@ -191,7 +193,7 @@ export function Knowledge() { } }, [permissionConfig.hideKnowledgeBaseTab, router, workspaceId]) - const { knowledgeBases, error } = useKnowledgeBasesList(workspaceId) + const { knowledgeBases, isLoading, isPlaceholderData, error } = useKnowledgeBasesList(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) /** * Indexed once: `ownerCell` resolves a member per row, so passing the raw array makes the @@ -1338,6 +1340,17 @@ export function Knowledge() { return tags }, [connectorFilter, contentFilter, ownerFilter, members]) + const showEmptyState = isResourceListEmpty({ + rowCount: rows.length, + isLoading, + isPlaceholderData, + error, + search: debouncedSearchQuery, + filterCount: filterTags.length, + folderId: currentFolderId, + foldersResolved, + }) + return ( <> @@ -1355,6 +1368,11 @@ export function Knowledge() { filter={filterConfig} /> + ) : undefined + } columns={COLUMNS} rows={rows} selectable={canEdit ? selectableConfig : undefined} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 17cc80be70c..da75a34a8da 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -58,6 +58,8 @@ import type { SortConfig, } from '@/app/workspace/[workspaceId]/components' import { Resource, type ResourceTableHandle } from '@/app/workspace/[workspaceId]/components' +import { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' +import { isResourceListEmpty } from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty' import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters' import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state' import { @@ -900,6 +902,16 @@ export default function Logs() { setTimeRange, ]) + /** Logs has no folder navigation, so the graphic means "nothing has ever run here". */ + const showEmptyState = isResourceListEmpty({ + rowCount: rows.length, + isLoading: logsQuery.isLoading, + isPlaceholderData: logsQuery.isPlaceholderData, + error: logsQuery.error, + search: debouncedSearchQuery, + filterCount: filterTags.length, + }) + const workflowsData = useMemo( () => Object.values(allWorkflows).map((w) => ({ @@ -1191,6 +1203,7 @@ export default function Logs() { ) : ( : undefined} virtualized columns={LOG_COLUMNS} rows={rows} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index dc5e89a8742..a7c309907b5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -53,6 +53,8 @@ import { useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' +import { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' +import { isResourceListEmpty } from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { @@ -135,7 +137,12 @@ export function Tables() { // mutation service) invalidates the list so this view refetches without waiting for staleness. useWorkspaceTablesRoom(workspaceId) - const { data: tables = EMPTY_TABLES, error } = useTablesList(workspaceId) + const { + data: tables = EMPTY_TABLES, + isLoading, + isPlaceholderData, + error, + } = useTablesList(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) const pinnedTableIds = usePinnedIds(workspaceId, 'table') // Folder pins live in their own `resourceType` namespace, so a page listing @@ -731,6 +738,17 @@ export function Tables() { return tags }, [rowCountFilter, ownerFilter, membersById, setRowCountFilter, setOwnerFilter]) + const showEmptyState = isResourceListEmpty({ + rowCount: rows.length, + isLoading, + isPlaceholderData, + error, + search: debouncedSearchTerm, + filterCount: filterTags.length, + folderId: currentFolderId, + foldersResolved, + }) + const handleContentContextMenu = useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement @@ -1267,6 +1285,14 @@ export function Tables() { filter={filterConfig} /> + ) : undefined + } columns={COLUMNS} rows={rows} selectable={canEdit ? selectableConfig : undefined} diff --git a/apps/sim/components/empty-state/empty-state.tsx b/apps/sim/components/empty-state/empty-state.tsx new file mode 100644 index 00000000000..538f740714e --- /dev/null +++ b/apps/sim/components/empty-state/empty-state.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react' + +interface EmptyStateProps { + title: string + description: string + graphic?: ReactNode + action?: ReactNode +} + +/** + * Shared platform empty-state frame with a visual, concise guidance, and an optional action. + * + * The frame owns the action row's layout so every empty state's chips sit identically — + * callers pass the chips themselves, not a wrapper. + */ +export function EmptyState({ title, description, graphic, action }: EmptyStateProps) { + return ( +
+ {graphic ?
{graphic}
: null} +

{title}

+

+ {description} +

+ {action ?
{action}
: null} +
+ ) +} diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style.ts b/apps/sim/components/iso/iso-illustration-style.ts similarity index 68% rename from apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style.ts rename to apps/sim/components/iso/iso-illustration-style.ts index bbb7a06986d..a97971a06b0 100644 --- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style.ts +++ b/apps/sim/components/iso/iso-illustration-style.ts @@ -15,15 +15,23 @@ export const ISO_FILL_PROPS = { pointerEvents: 'none', } satisfies SVGProps -export function createIsoLineProps(className: string, stroke: string): SVGProps { +/** + * Contour props for an iso face. `strokeWidth` defaults to the shared weight; a mark + * drawn at a different scale passes its own so the recipe stays single-sourced. + */ +export function createIsoLineProps( + className: string | undefined, + stroke: string, + strokeWidth: number = ISO_LINE_STROKE_WIDTH +): SVGProps { return { - className, + ...(className ? { className } : {}), fill: 'none', pathLength: 1, pointerEvents: 'none', opacity: 1, stroke, - strokeWidth: ISO_LINE_STROKE_WIDTH, + strokeWidth, strokeLinecap: 'round', strokeLinejoin: 'round', } diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index ef913731a9c..a93c3db63a8 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -10,49 +10,49 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 2919, + "modules": 2959, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1327, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 982, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 847, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 844, - "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 309, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 295, - "apps/sim/lib/auth/index.ts": 204 + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1346, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1000, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 865, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 862, + "apps/sim/triggers/registry.ts": 448, + "apps/sim/blocks/registry.ts": 312, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 304, + "apps/sim/lib/auth/index.ts": 208 } }, "app/workspace/[workspaceId]/files/[fileId]/page.tsx": { - "modules": 1931, + "modules": 1968, "gateways": { - "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 337, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 280, - "apps/sim/lib/auth/index.ts": 210, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 143, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 129, - "apps/sim/lib/api/contracts/index.ts": 107, - "apps/sim/lib/webhooks/providers/index.ts": 100 + "apps/sim/triggers/registry.ts": 448, + "apps/sim/blocks/registry.ts": 335, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 293, + "apps/sim/lib/auth/index.ts": 214, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 130, + "apps/sim/lib/api/contracts/index.ts": 108, + "apps/sim/lib/webhooks/providers/index.ts": 102 } }, "app/workspace/[workspaceId]/files/[fileId]/view/page.tsx": { - "modules": 59, + "modules": 58, "gateways": { - "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 58, - "apps/sim/hooks/queries/workspace-files.ts": 55 + "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 57, + "apps/sim/hooks/queries/workspace-files.ts": 54 } }, "app/workspace/[workspaceId]/files/page.tsx": { - "modules": 1931, + "modules": 1968, "gateways": { - "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 337, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 282, - "apps/sim/lib/auth/index.ts": 210, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 143, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 129, - "apps/sim/lib/api/contracts/index.ts": 107, - "apps/sim/lib/webhooks/providers/index.ts": 100 + "apps/sim/triggers/registry.ts": 448, + "apps/sim/blocks/registry.ts": 335, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 295, + "apps/sim/lib/auth/index.ts": 214, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 144, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 130, + "apps/sim/lib/api/contracts/index.ts": 108, + "apps/sim/lib/webhooks/providers/index.ts": 102 } }, "app/workspace/[workspaceId]/home/layout.tsx": { @@ -60,25 +60,25 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 2919, + "modules": 2959, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1327, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 982, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 847, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 844, - "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 309, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 295, - "apps/sim/lib/auth/index.ts": 204 + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1346, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1000, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 865, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 862, + "apps/sim/triggers/registry.ts": 448, + "apps/sim/blocks/registry.ts": 312, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 304, + "apps/sim/lib/auth/index.ts": 208 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { - "modules": 1284, + "modules": 1300, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1259, - "apps/sim/blocks/registry.ts": 935, - "apps/sim/triggers/index.ts": 482, - "apps/sim/lib/api/contracts/index.ts": 127, + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1273, + "apps/sim/blocks/registry.ts": 919, + "apps/sim/triggers/index.ts": 484, + "apps/sim/lib/api/contracts/index.ts": 128, "apps/sim/stores/workflows/registry/store.ts": 67, "apps/sim/hooks/queries/deployments.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, @@ -86,11 +86,11 @@ } }, "app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx": { - "modules": 1266, + "modules": 1283, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1265, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 339, + "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1282, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 342, "apps/sim/lib/api/contracts/index.ts": 134, "apps/sim/stores/workflows/registry/store.ts": 64, "apps/sim/hooks/queries/deployments.ts": 61, @@ -99,12 +99,12 @@ } }, "app/workspace/[workspaceId]/integrations/page.tsx": { - "modules": 1269, + "modules": 1287, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 993, - "apps/sim/blocks/registry.ts": 936, - "apps/sim/triggers/index.ts": 482, - "apps/sim/lib/api/contracts/index.ts": 129, + "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1007, + "apps/sim/blocks/registry.ts": 920, + "apps/sim/triggers/index.ts": 484, + "apps/sim/lib/api/contracts/index.ts": 130, "apps/sim/stores/workflows/registry/store.ts": 67, "apps/sim/hooks/queries/deployments.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, @@ -112,68 +112,68 @@ } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx": { - "modules": 1482, + "modules": 1501, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1203, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 330, - "apps/sim/blocks/registry-maps.ts": 327, - "apps/sim/lib/api/contracts/index.ts": 119, - "apps/sim/connectors/registry.ts": 62, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1218, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 338, + "apps/sim/blocks/registry-maps.ts": 335, + "apps/sim/lib/api/contracts/index.ts": 120, + "apps/sim/connectors/registry.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 52 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 55 } }, "app/workspace/[workspaceId]/knowledge/[id]/page.tsx": { - "modules": 1483, + "modules": 1504, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1204, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 330, - "apps/sim/blocks/registry-maps.ts": 327, - "apps/sim/lib/api/contracts/index.ts": 119, - "apps/sim/connectors/registry.ts": 62, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1221, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 338, + "apps/sim/blocks/registry-maps.ts": 335, + "apps/sim/lib/api/contracts/index.ts": 120, + "apps/sim/connectors/registry.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 52 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 55 } }, "app/workspace/[workspaceId]/knowledge/page.tsx": { - "modules": 2138, + "modules": 2177, "gateways": { - "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 325, + "apps/sim/triggers/registry.ts": 448, + "apps/sim/blocks/registry.ts": 333, "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 273, "apps/sim/lib/knowledge/application/knowledge-bases.ts": 218, - "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 168, - "apps/sim/lib/auth/index.ts": 158, - "apps/sim/lib/knowledge/orchestration/index.ts": 141, - "apps/sim/lib/knowledge/orchestration/connectors.ts": 136 + "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 182, + "apps/sim/lib/auth/index.ts": 163, + "apps/sim/lib/knowledge/orchestration/index.ts": 138, + "apps/sim/lib/knowledge/orchestration/connectors.ts": 133 } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 1974, + "modules": 2003, "gateways": { - "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 324, - "apps/sim/lib/auth/index.ts": 272, - "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 270, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 262, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 165, - "apps/sim/lib/api/contracts/index.ts": 109, - "apps/sim/lib/webhooks/providers/index.ts": 100 + "apps/sim/triggers/registry.ts": 448, + "apps/sim/blocks/registry.ts": 332, + "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 279, + "apps/sim/lib/auth/index.ts": 278, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 271, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 176, + "apps/sim/lib/api/contracts/index.ts": 110, + "apps/sim/lib/webhooks/providers/index.ts": 102 } }, "app/workspace/[workspaceId]/logs/page.tsx": { - "modules": 1711, + "modules": 1737, "gateways": { - "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1434, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/app/workspace/[workspaceId]/logs/components/index.ts": 419, - "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts": 363, - "apps/sim/blocks/registry.ts": 326, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 319, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 282, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 252 + "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1456, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/app/workspace/[workspaceId]/logs/components/index.ts": 421, + "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts": 365, + "apps/sim/blocks/registry.ts": 329, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 321, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 286, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 255 } }, "app/workspace/[workspaceId]/page.tsx": { @@ -185,16 +185,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { - "modules": 2003, + "modules": 2039, "gateways": { - "apps/sim/triggers/registry.ts": 446, - "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 420, - "apps/sim/blocks/registry.ts": 325, - "apps/sim/lib/auth/index.ts": 282, - "apps/sim/lib/api/contracts/index.ts": 105, - "apps/sim/lib/webhooks/providers/index.ts": 100, + "apps/sim/triggers/registry.ts": 448, + "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 434, + "apps/sim/blocks/registry.ts": 328, + "apps/sim/lib/auth/index.ts": 288, + "apps/sim/lib/api/contracts/index.ts": 107, + "apps/sim/lib/webhooks/providers/index.ts": 102, "apps/sim/lib/api/contracts/tools/index.ts": 59, - "apps/sim/lib/workflows/lifecycle.ts": 48 + "apps/sim/lib/uploads/utils/file-utils.server.ts": 42 } }, "app/workspace/[workspaceId]/settings/billing/credit-usage/layout.tsx": { @@ -202,16 +202,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx": { - "modules": 1588, + "modules": 1610, "gateways": { - "apps/sim/lib/auth/index.ts": 1455, - "apps/sim/triggers/index.ts": 447, - "apps/sim/blocks/registry.ts": 339, - "apps/sim/blocks/registry-maps.ts": 336, - "apps/sim/lib/api/contracts/index.ts": 122, - "apps/sim/lib/webhooks/providers/index.ts": 100, - "apps/sim/stores/workflows/registry/store.ts": 64, - "apps/sim/lib/api/contracts/tools/index.ts": 60 + "apps/sim/lib/auth/index.ts": 1475, + "apps/sim/triggers/index.ts": 449, + "apps/sim/blocks/registry.ts": 342, + "apps/sim/blocks/registry-maps.ts": 339, + "apps/sim/lib/api/contracts/index.ts": 123, + "apps/sim/lib/webhooks/providers/index.ts": 102, + "apps/sim/stores/workflows/registry/store.ts": 68, + "apps/sim/hooks/queries/deployments.ts": 62 } }, "app/workspace/[workspaceId]/settings/layout.tsx": { @@ -223,159 +223,159 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx": { - "modules": 1297, + "modules": 1317, "gateways": { - "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 1296, - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1005, - "apps/sim/components/permissions/index.ts": 992, - "apps/sim/components/permissions/add-people-modal.tsx": 983, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 981, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 343, - "apps/sim/blocks/registry-maps.ts": 340 + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 344, + "apps/sim/blocks/registry-maps.ts": 341, + "apps/sim/lib/api/contracts/index.ts": 135, + "apps/sim/stores/workflows/registry/store.ts": 64, + "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 63, + "apps/sim/hooks/queries/deployments.ts": 61, + "apps/sim/lib/api/contracts/tools/index.ts": 60 } }, "app/workspace/[workspaceId]/skills/[skillId]/page.tsx": { - "modules": 1388, + "modules": 1402, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1387, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 342, - "apps/sim/blocks/registry-maps.ts": 340, - "apps/sim/lib/api/contracts/index.ts": 125, + "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1401, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 343, + "apps/sim/blocks/registry-maps.ts": 341, + "apps/sim/lib/api/contracts/index.ts": 126, "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 89, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 86, "apps/sim/lib/api/contracts/tools/index.ts": 60 } }, "app/workspace/[workspaceId]/skills/new/page.tsx": { - "modules": 1386, + "modules": 1400, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1385, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 342, - "apps/sim/blocks/registry-maps.ts": 340, - "apps/sim/lib/api/contracts/index.ts": 125, + "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1399, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 343, + "apps/sim/blocks/registry-maps.ts": 341, + "apps/sim/lib/api/contracts/index.ts": 126, "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 89, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 86, "apps/sim/lib/api/contracts/tools/index.ts": 60 } }, "app/workspace/[workspaceId]/skills/page.tsx": { - "modules": 1250, + "modules": 1268, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 974, - "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 962, - "apps/sim/blocks/registry.ts": 950, - "apps/sim/blocks/registry-maps.ts": 948, - "apps/sim/triggers/index.ts": 482, - "apps/sim/lib/api/contracts/index.ts": 134, - "apps/sim/stores/workflows/registry/store.ts": 68, - "apps/sim/hooks/queries/deployments.ts": 62 + "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 988, + "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 976, + "apps/sim/blocks/registry.ts": 964, + "apps/sim/blocks/registry-maps.ts": 962, + "apps/sim/triggers/index.ts": 484, + "apps/sim/lib/api/contracts/index.ts": 135, + "apps/sim/stores/workflows/registry/store.ts": 74, + "apps/sim/hooks/queries/deployments.ts": 68 } }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 2204, + "modules": 2236, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 577, - "apps/sim/triggers/registry.ts": 446, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 331, - "apps/sim/blocks/registry.ts": 309, - "apps/sim/lib/auth/index.ts": 303, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 289, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 261, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 232 + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 584, + "apps/sim/triggers/registry.ts": 448, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 335, + "apps/sim/blocks/registry.ts": 312, + "apps/sim/lib/auth/index.ts": 309, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 293, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 265, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 235 } }, "app/workspace/[workspaceId]/tables/page.tsx": { - "modules": 1788, + "modules": 1827, "gateways": { - "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 336, - "apps/sim/lib/auth/index.ts": 293, - "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 126, - "apps/sim/lib/api/contracts/index.ts": 111, - "apps/sim/lib/webhooks/providers/index.ts": 100, + "apps/sim/triggers/registry.ts": 448, + "apps/sim/blocks/registry.ts": 334, + "apps/sim/lib/auth/index.ts": 299, + "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 140, + "apps/sim/lib/api/contracts/index.ts": 112, + "apps/sim/lib/webhooks/providers/index.ts": 102, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 50 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 53 } }, "app/workspace/[workspaceId]/upgrade/page.tsx": { - "modules": 267, + "modules": 268, "gateways": { - "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 260, - "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 213, - "apps/sim/lib/billing/client/upgrade.ts": 205, - "apps/sim/hooks/queries/organization.ts": 201, - "apps/sim/hooks/queries/workspace.ts": 193, - "apps/sim/lib/api/contracts/index.ts": 191, + "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 261, + "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 214, + "apps/sim/lib/billing/client/upgrade.ts": 206, + "apps/sim/hooks/queries/organization.ts": 202, + "apps/sim/hooks/queries/workspace.ts": 194, + "apps/sim/lib/api/contracts/index.ts": 192, "apps/sim/lib/api/contracts/tools/index.ts": 61, "apps/sim/lib/api/contracts/v1/index.ts": 38 } }, "app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": { - "modules": 2160, + "modules": 2187, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2159, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 540, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 458, - "apps/sim/blocks/registry.ts": 326, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 285, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 134 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2186, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 545, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 462, + "apps/sim/blocks/registry.ts": 329, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 289, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 145, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 138 } }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2187, + "modules": 2214, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2186, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 326, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 305, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 267, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 224, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 140, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 133 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2213, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 329, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 308, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 270, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 227, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 143, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 136 } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2160, + "modules": 2187, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 904, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 458, - "apps/sim/blocks/registry.ts": 326, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 285, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 141, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 134, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 129 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 922, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 462, + "apps/sim/blocks/registry.ts": 329, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 289, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 145, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 139, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 138 } }, "app/workspace/layout.tsx": { - "modules": 1208, + "modules": 1219, "gateways": { - "apps/sim/app/workspace/providers/socket-provider.tsx": 1198, - "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 343, - "apps/sim/blocks/registry-maps.ts": 340, - "apps/sim/lib/api/contracts/index.ts": 138, - "apps/sim/stores/workflows/registry/store.ts": 65, - "apps/sim/hooks/queries/deployments.ts": 62, - "apps/sim/lib/api/contracts/tools/index.ts": 60 + "apps/sim/app/workspace/providers/socket-provider.tsx": 1209, + "apps/sim/triggers/registry.ts": 483, + "apps/sim/blocks/registry.ts": 344, + "apps/sim/blocks/registry-maps.ts": 341, + "apps/sim/lib/api/contracts/index.ts": 139, + "apps/sim/stores/workflows/registry/store.ts": 67, + "apps/sim/hooks/queries/deployments.ts": 64, + "apps/sim/lib/workflows/comparison/compare.ts": 61 } }, "app/workspace/page.tsx": { - "modules": 1202, + "modules": 1213, "gateways": { - "apps/sim/lib/auth/stale-session-recovery.ts": 971, - "apps/sim/triggers/index.ts": 482, - "apps/sim/blocks/registry.ts": 343, - "apps/sim/blocks/registry-maps.ts": 340, - "apps/sim/lib/api/contracts/index.ts": 137, - "apps/sim/stores/workflows/registry/store.ts": 64, - "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/hooks/queries/deployments.ts": 58 + "apps/sim/lib/auth/stale-session-recovery.ts": 981, + "apps/sim/triggers/index.ts": 484, + "apps/sim/blocks/registry.ts": 344, + "apps/sim/blocks/registry-maps.ts": 341, + "apps/sim/lib/api/contracts/index.ts": 138, + "apps/sim/stores/workflows/registry/store.ts": 66, + "apps/sim/hooks/queries/deployments.ts": 60, + "apps/sim/lib/api/contracts/tools/index.ts": 60 } } } From fc2087b4c180051b3828bb1ed611d224da388256 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 19 Aug 2026 12:05:15 -0700 Subject: [PATCH 07/14] polish(resources): stop the tables grid reappearing past its fade (#6853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid is authored larger than the box it fades inside — 358x160 drawn into 320x148, deliberately, so it runs off two edges. But a mask tile is sized to the element box and `mask-repeat` starts at `repeat`, so the overflow landed in the *next* tile at the opaque head of the gradient: a solid strip of cells reappeared just past where the fade had finished dissolving. The frame now clips, and every fade in the set pins `no-repeat` rather than relying on its subject happening to fit. Also: - `--border-1` is a legacy alias; the new files use the canonical `--border`, which `resource.tsx` was already using two lines above them. - `createIsoLineProps` returns `SVGAttributes`. It never returns a `ref`, and `ref` was the only member forcing an element type — which had made the knowledge mark reach for an `SVGPathElement & SVGCircleElement` intersection to spread onto both. `className` moves last so no caller passes `undefined` positionally to skip it. - `isResourceListEmpty` is exported from the components barrel its four callers already import `Resource` from, instead of being reached past it. - `emptyState` sits after `rows` on all four tables; three had it leading. - Two TSDoc blocks claimed things the code stopped doing: the folder graphic does not have three fill tiers, and the empty-state wrapper grows the slot but does not centre it. Adds the predicate's unit test — it is a pure eight-clause function that decides whether a page tells someone they have nothing, and it had none. Verified it fails when the placeholder and folder guards are removed. --- .../iso-marks/iso-build-illustration.tsx | 2 +- .../iso-marks/iso-ingest-illustration.tsx | 2 +- .../iso-marks/iso-integrate-illustration.tsx | 2 +- .../iso-marks/iso-monitor-illustration.tsx | 2 +- .../[workspaceId]/components/index.ts | 1 + .../files-empty-state.tsx | 16 +++--- .../resource-empty-state/knowledge-iso.tsx | 15 +++--- .../resource-empty-state/logs-empty-state.tsx | 5 +- .../components/resource-empty-state/mask.ts | 7 +++ .../tables-empty-state.tsx | 12 +++-- .../resource/is-resource-list-empty.test.ts | 53 +++++++++++++++++++ .../components/resource/resource.tsx | 5 +- .../workspace/[workspaceId]/files/files.tsx | 6 +-- .../[workspaceId]/knowledge/knowledge.tsx | 6 +-- .../app/workspace/[workspaceId]/logs/logs.tsx | 9 ++-- .../workspace/[workspaceId]/tables/tables.tsx | 6 +-- .../components/iso/iso-illustration-style.ts | 16 +++--- 17 files changed, 119 insertions(+), 46 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.test.ts diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx index 00f921aaca9..1a605b3ddb6 100644 --- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx +++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx @@ -14,7 +14,7 @@ export interface IsoBuildIllustrationProps { const STROKE_PAINT = ISO_STROKE -const LINE_PROPS = createIsoLineProps('iso-build-line', STROKE_PAINT) +const LINE_PROPS = createIsoLineProps(STROKE_PAINT, undefined, 'iso-build-line') /** Isometric tile module the floor grid and the columns are built on. */ const TILE_WIDTH = 38 diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx index 980bb792339..92289a3972d 100644 --- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx +++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx @@ -18,7 +18,7 @@ export interface IsoIngestIllustrationProps { const STROKE_PAINT = ISO_STROKE -const LINE_PROPS = createIsoLineProps('iso-ingest-line', STROKE_PAINT) +const LINE_PROPS = createIsoLineProps(STROKE_PAINT, undefined, 'iso-ingest-line') /** * Inline supplied illustration for the Context area - a central store diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx index 4981feb35b0..51f78761cd2 100644 --- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx +++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx @@ -14,7 +14,7 @@ export interface IsoIntegrateIllustrationProps { const STROKE_PAINT = ISO_STROKE -const LINE_PROPS = createIsoLineProps('iso-integrate-line', STROKE_PAINT) +const LINE_PROPS = createIsoLineProps(STROKE_PAINT, undefined, 'iso-integrate-line') /** * Inline supplied illustration for the Integrate area - a three-tier isometric diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx index b70bab7a7b1..fb0b921308c 100644 --- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx +++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx @@ -14,7 +14,7 @@ export interface IsoMonitorIllustrationProps { const STROKE_PAINT = ISO_STROKE -const LINE_PROPS = createIsoLineProps('iso-monitor-line', STROKE_PAINT) +const LINE_PROPS = createIsoLineProps(STROKE_PAINT, undefined, 'iso-monitor-line') /** * Inline supplied illustration for the Monitor area - an isometric housing whose diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 3aeaaeebbeb..4aa4ad52ac7 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -1,3 +1,4 @@ +export { isResourceListEmpty } from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty' export { ConversationListItem } from './conversation-list-item' export type { ErrorBoundaryProps, ErrorStateProps } from './error' export { ErrorShell, ErrorState } from './error' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx index 09a5f802c6c..ae1735a7015 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx @@ -2,11 +2,12 @@ import { Chip, cn } from '@sim/emcn' import { Upload } from '@sim/emcn/icons' import { EmptyState } from '@/components/empty-state/empty-state' import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' +import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' const FILES_DOCS_URL = 'https://docs.sim.ai/files' /** - * Hairline contours, matching the tables grid's 1px `--border-1` rules and the + * Hairline contours, matching the tables grid's 1px `--border` rules and the * knowledge mark's thinned strokes — the graphics sit one nav item apart, so a * heavier outline here would read as a different illustration system. * @@ -15,7 +16,7 @@ const FILES_DOCS_URL = 'https://docs.sim.ai/files' * solid mid-grey body made this the heaviest thing on the page. */ const HAIRLINE = { - stroke: 'var(--border-1)', + stroke: 'var(--border)', strokeWidth: 1.1, strokeLinejoin: 'round' as const, } as const @@ -48,12 +49,13 @@ const FOLDER_FADE = /** * A folder held open with sheets standing proud of its front panel. * - * Depth is carried by the surface ramp rather than by shadow: the back panel is the - * darkest tier, the sheets the lightest, the front panel between them. Shadows + * Depth is carried by the surface ramp rather than by shadow: the back panel sits a + * tier down on `--surface-4`, everything in front of it on `--surface-2`. Shadows * would need separate light and dark recipes; the ramp inverts on its own. * - * Every outer corner shares the same 10-unit radius so the silhouette reads as a - * single drawn shape — mixing radii makes the corners fight each other at this size. + * Unlike the tables grid — where a skeleton bar has no outline and so needs mid-grey + * ink to survive a light page — the hairline draws this shape, so the fills only have + * to separate one layer from the next and a near-white tier carries it. */ function FilesGraphic() { return ( @@ -64,7 +66,7 @@ function FilesGraphic() { fill='none' aria-hidden='true' focusable='false' - className={cn('block max-w-none shrink-0', FOLDER_FADE)} + className={cn('block max-w-none shrink-0', FOLDER_FADE, MASK_NO_REPEAT)} > diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso.tsx index 37cd2b50a9f..6f03e3659ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso.tsx @@ -7,6 +7,7 @@ import { ISO_FILL_PULSE_LOW, ISO_STROKE as ISO_STROKE_BASE, } from '@/components/iso/iso-illustration-style' +import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' const COS_30 = Math.cos(Math.PI / 6) @@ -61,14 +62,14 @@ const SLABS: Box[] = [0, 90, 180].map((offset) => ({ * Lighter and thinner than the landing marks draw them. * * Those marks are the focal art of their section; here the mark sits beside a - * ruled grid and a skeleton feed whose lines are 1px of `--border-1`. Carrying + * ruled grid and a skeleton feed whose lines are 1px of `--border`. Carrying * the landing's full-weight contour made the volumes read as ink next to those, - * so the shared stroke is mixed toward `--border-1` and thinned to land near a + * so the shared stroke is mixed toward `--border` and thinned to land near a * hairline once the mark is scaled to empty-state size. Only the width diverges * from the shared recipe; the fills are imported so a change to the iso ramp * reaches this mark too. */ -const ISO_STROKE = `color-mix(in srgb, ${ISO_STROKE_BASE} 55%, var(--border-1))` +const ISO_STROKE = `color-mix(in srgb, ${ISO_STROKE_BASE} 55%, var(--border))` /** Darker than any outer face — the bore's wall turns away from the light. */ const ISO_FILL_BORE = ISO_FILL_PULSE_LOW const KNOWLEDGE_STROKE_WIDTH = 1.9 @@ -145,11 +146,7 @@ const STACK_FADE = '[-webkit-mask-image:linear-gradient(to_right,#000_56%,transparent_100%),linear-gradient(to_bottom,transparent_0%,#000_40%)] [mask-image:linear-gradient(to_right,#000_56%,transparent_100%),linear-gradient(to_bottom,transparent_0%,#000_40%)] [-webkit-mask-composite:source-in] [mask-composite:intersect]' /** Shared iso contour recipe at this mark's own weight; spread onto both paths and circles. */ -const LINE_PROPS = createIsoLineProps( - undefined, - ISO_STROKE, - KNOWLEDGE_STROKE_WIDTH -) +const LINE_PROPS = createIsoLineProps(ISO_STROKE, KNOWLEDGE_STROKE_WIDTH) /** * Down the hole: the near mouth is floored with the wall tone, then the far mouth @@ -198,7 +195,7 @@ export function KnowledgeIsoMark() { fill='none' aria-hidden='true' focusable='false' - className={cn('block max-w-none shrink-0', STACK_FADE)} + className={cn('block max-w-none shrink-0', STACK_FADE, MASK_NO_REPEAT)} > diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx index 6ea0d5ba336..443e1155888 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx @@ -1,6 +1,7 @@ import { cn } from '@sim/emcn' import { EmptyState } from '@/components/empty-state/empty-state' import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' +import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' const LOGS_DOCS_URL = 'https://docs.sim.ai/logs-debugging' @@ -33,14 +34,14 @@ const FEED_FADE = /** Four rows, sized to the ~148px the other resource graphics occupy so the frame centres the set alike. */ function LogsGraphic() { return ( -