From 6b3a32087871baceca52bc8ff95e7b5400dd29ce Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Sat, 7 Nov 2026 13:22:46 -0800 Subject: [PATCH] refine(platform): load without skeletons and align integration icons - Credential rows on an app's page wear that app's mark, so a shared Google credential no longer shows Gmail under Google BigQuery. - The MCP glyph is an interface icon and takes the shared icon ink, like the skill and key glyphs. Integration marks keep their own paint. - The integration center renders its heading, and app details their Back link, from the same nuqs state that picks the view. Going back no longer flashes the previous view's chrome for a few frames. - Sidebar, Home and Integrations paint nothing while loading: no route loading.tsx, no widget skeletons, no loading copy. The shell's unkeyed Suspense keeps the current page through a navigation, and a page load paints the sidebar first. The calendar keeps its hour grid while a day loads. The rule is recorded in apps/sim/platform/AGENTS.md. --- apps/sim/platform/AGENTS.md | 4 +- .../platform-shell/platform-shell.tsx | 44 ++- .../activity-chart/activity-chart.tsx | 2 - .../activity-list/activity-list.tsx | 2 - .../_components/calendar/calendar-day.tsx | 4 +- .../connected-widget/connected-widget.tsx | 2 - .../home-dashboard-skeleton.tsx | 52 --- .../home-dashboard/home-dashboard.module.css | 27 -- .../home-dashboard/home-dashboard.tsx | 2 - .../home/_components/live-home/live-home.tsx | 6 +- .../widget-skeleton.module.css | 54 --- .../widget-skeleton/widget-skeleton.tsx | 40 --- .../sim/platform/app/(shell)/home/loading.tsx | 5 - .../credential-list/credential-list.tsx | 9 +- .../integration-details.tsx | 26 +- .../integrations-page/integrations-page.tsx | 164 ++++----- .../integration-center/integration-center.tsx | 311 +++++++++--------- .../integrations-page/integrations-route.tsx | 124 ++++--- .../tool-permissions-panel.tsx | 3 +- apps/sim/platform/app/(shell)/loading.tsx | 7 - packages/emcn/src/catalog/icon-geometry.tsx | 2 +- .../check-platform-convergence.baseline.json | 3 - 22 files changed, 356 insertions(+), 537 deletions(-) delete mode 100644 apps/sim/platform/app/(shell)/home/_components/home-dashboard/home-dashboard-skeleton.tsx delete mode 100644 apps/sim/platform/app/(shell)/home/_components/widget-skeleton/widget-skeleton.module.css delete mode 100644 apps/sim/platform/app/(shell)/home/_components/widget-skeleton/widget-skeleton.tsx delete mode 100644 apps/sim/platform/app/(shell)/home/loading.tsx delete mode 100644 apps/sim/platform/app/(shell)/loading.tsx diff --git a/apps/sim/platform/AGENTS.md b/apps/sim/platform/AGENTS.md index 900bdf22ef3..ce8d8160295 100644 --- a/apps/sim/platform/AGENTS.md +++ b/apps/sim/platform/AGENTS.md @@ -26,7 +26,7 @@ Keep routes thin. Colocate each feature component, its CSS Module and private he Use the shared semantic gaps from `@sim/emcn/styles/global.css` and Platform layout roles from `@sim/emcn-platform/styles/platform.css`. Title/detail stacks, sibling list items and adjacent actions each have a 2px role. The 8px row gap separates leading art from copy. Separate sibling rows with the list gap. Reserve lines for surface boundaries and data rules such as chart axes. Align a leading avatar or glyph with the first text line; center trailing metadata, badges and actions against the whole row. Align action ink with adjacent text by extending the hit area into the margin. Chat thinking, tool labels and prose share that text edge. Composer and message action groups use the same compact gap. Custom calendar, chart and summary rows use the same type and spacing. An icon that identifies a two-line notification aligns to the title; its trailing action group centers against the full row. Email thread counts and the notification trigger use the same centered inline label/count spacing. The notification trigger takes the compact secondary (Invite) button surface, and has no chevron. The tray applies control elevation once around its scroll viewport; stacked notices do not cast shadows on one another. An active popup stays above the tray, including at narrow widths. Labelled range pickers and inline ellipsis menus need accessible names, not redundant tooltips; keep tooltips on top-level icon actions. -Every Platform reading page uses `PageColumn` for the same centered, border-box column, with Home’s 1296px content maximum and fluid 16–48px gutters. `PageOverflow` extends surfaces 8px around the text edge. Home, Chat, Search, Settings, integration details, resource directories and people pages share these edges. Embedded editors and split preview canvases retain their available working space. PageColumn owns geometry only and preserves Home’s legacy theme isolation. Home and Integrations use the shared page-heading inset inside the moving content. The shell separately reserves clearance for its sidebar opener or native titlebar; responsive layout does not change either page’s own heading inset. The unpadded viewport keeps the fade at the window edge. The web sidebar opener belongs to the shell, outside the scrolling page. It stays at the expanded toolbar’s 12px top inset as the center content scrolls, including on Home and in the drawer layout. Keep the button reachable without covering page actions. Electron keeps both controls in its native titlebar. Keep the same shell inset immediately above and below the footer. Major dashboard/sidebar sections are independently spaced at 24px. Home uses the compact 3px heading gap from greeting to description, Recents to links and activity title to total. Use the 12px content gap from the Sim heading row to its input. Home widget content uses the 16px gap measured from the activity total to the chart, and from the attention, Email and Calendar labels to their first content. Header and calendar controls extend around their label line without adding height to these gaps. The ordinary 12px section gap still serves other compositions. Home has two inline alignment edges: headings and leading content share the reading-column text edge; the composer, row hover surfaces and outer control bounds extend by the shared 8px row inset. Keep Sim and the Build/Search toggle on the text edges, and align the composer and its loading placeholder with the leftmost recent and Email row on the left and the attention row and menu controls on the right. Measure the 24px dashboard gap between component boxes, including their inline hover overhang. Chart and widget boxes own that inset; the column layout allows for both insets so neighboring surfaces remain 24px apart. Vertical widget gaps use the same 24px as the description-to-Sim gap. Responsive layouts can wrap, collapse or split panels; button height, padding and type stay stable. Composer draft and placeholder keep the same type role across widths and themes; browser zoom and user text scaling still apply. +Every Platform reading page uses `PageColumn` for the same centered, border-box column, with Home’s 1296px content maximum and fluid 16–48px gutters. `PageOverflow` extends surfaces 8px around the text edge. Home, Chat, Search, Settings, integration details, resource directories and people pages share these edges. Embedded editors and split preview canvases retain their available working space. PageColumn owns geometry only and preserves Home’s legacy theme isolation. Home and Integrations use the shared page-heading inset inside the moving content. The shell separately reserves clearance for its sidebar opener or native titlebar; responsive layout does not change either page’s own heading inset. The unpadded viewport keeps the fade at the window edge. The web sidebar opener belongs to the shell, outside the scrolling page. It stays at the expanded toolbar’s 12px top inset as the center content scrolls, including on Home and in the drawer layout. Keep the button reachable without covering page actions. Electron keeps both controls in its native titlebar. Keep the same shell inset immediately above and below the footer. Major dashboard/sidebar sections are independently spaced at 24px. Home uses the compact 3px heading gap from greeting to description, Recents to links and activity title to total. Use the 12px content gap from the Sim heading row to its input. Home widget content uses the 16px gap measured from the activity total to the chart, and from the attention, Email and Calendar labels to their first content. Header and calendar controls extend around their label line without adding height to these gaps. The ordinary 12px section gap still serves other compositions. Home has two inline alignment edges: headings and leading content share the reading-column text edge; the composer, row hover surfaces and outer control bounds extend by the shared 8px row inset. Keep Sim and the Build/Search toggle on the text edges, and align the composer with the leftmost recent and Email row on the left and the attention row and menu controls on the right. Measure the 24px dashboard gap between component boxes, including their inline hover overhang. Chart and widget boxes own that inset; the column layout allows for both insets so neighboring surfaces remain 24px apart. Vertical widget gaps use the same 24px as the description-to-Sim gap. Responsive layouts can wrap, collapse or split panels; button height, padding and type stay stable. Composer draft and placeholder keep the same type role across widths and themes; browser zoom and user text scaling still apply. Use `ControlRow` for the sidebar, identity controls, panel tabs, recent links, wrapping workspace choices and horizontal destination links. The row owns its 30px height, 8px inset and the placement and hover of a 24px action, including a leading remove button that replaces an existing glyph. Workspace names, the Workspaces launcher and its tab title are text-only. A recent without a glyph keeps its remove action at the end. A pinned workspace or chat shows its pin in the row action slot. Hover or opening the menu reveals the ellipsis in place. On touch, tapping the resting pin opens the same menu. Sort pinned rows first, applying the section’s chosen order within each group. Keep the link, tab or button semantics on the rendered element, with sibling actions outside it. Whole section headers reveal their chevron on hover; identity controls need none. Ordinary buttons, icon triggers and rows share the sidebar Home radius (`--ui-control-radius`). Use `Button` / `IconButton` for actions and Base UI triggers, passing icons and labels through their slots. Buttons that open destructive confirmation dialogs use a quiet Button with `destructive`, including page actions, Settings rows, bulk actions and modal footer actions. It shares the menu row’s neutral resting paint and red hover recipe. Final confirmation actions retain the solid destructive variant. Close, clear and dismiss × controls keep their neutral hover; their glyph alone never makes an action destructive. The composer toolbar owns its round actions. Switches and segmented controls keep their pill shape. Clicking the selected half of a two-choice segment switches to the other half. Native keyboard choice navigation still selects its destination. @@ -79,6 +79,8 @@ Each `lib/data/.ts` wraps the existing hook and maps its contract shape Views that fixtures also render stay prop-driven. A route composes `data` and a named `actions` interface, filled by session state on fixtures and by mutations on live data. A surface that exists only on live data, such as a Settings section, People or Search, reads its domain's hooks from `lib/data` directly. No fixture branch inside a view. Hide what has no backend and record it in the [parity ledger](docs/org-parity.md); never fake it. Distinguish loading, empty and failure. A list reads `status` rather than treating empty as loading. +Loading paints nothing. Static chrome (headings, labels, menus, the composer, a calendar's hour grid) renders at once; data-backed content appears when its read settles. No skeletons, shimmer, placeholder blocks or loading copy in the shell, Home or Integrations. A Home or Integrations route has no `loading.tsx`: the shell's unkeyed `` around the page keeps the current page on screen through a client navigation, and on a page load paints the sidebar before the page. One component decides which view a URL shows, and that same component renders the view's heading and Back link from the same `nuqs` state. A second reader, such as Next's `useSearchParams`, settles a few frames later and flashes the previous view's chrome. + A server read goes through `serverRequestJson`, which gives every read a deadline. A first-paint read lives in `lib/data/prefetch-.ts` under the hook's own key and `staleTime`, through `prefetchRequestJson`; it may never block or fail the page, so the client fetches what it gave up. `resolvePlatformMode` decides each server entry once: `fixture` with `SIM_PLATFORM_FIXTURES=1`, `live` with `SIM_PLATFORM_API_ORIGIN`, otherwise `none`, today's empty rendering. Load the seam with a dynamic import behind the `live` check, so fixture servers never evaluate it. The viewer's organization comes from membership on the server, read once per request by `readOrganizationSurface`. Platform URLs carry no organization. The legacy organization layout redirects flagged organizations here through `lib/platform/routes.ts`. Link out to a legacy route only with `legacyRoutes`, which builds an absolute URL, and only to a route that mapper leaves unmapped. Linking to a mapped route makes the two surfaces redirect to each other. diff --git a/apps/sim/platform/app/(shell)/_components/platform-shell/platform-shell.tsx b/apps/sim/platform/app/(shell)/_components/platform-shell/platform-shell.tsx index 208255dbb06..dab842c1642 100644 --- a/apps/sim/platform/app/(shell)/_components/platform-shell/platform-shell.tsx +++ b/apps/sim/platform/app/(shell)/_components/platform-shell/platform-shell.tsx @@ -1,6 +1,14 @@ 'use client' -import { type ReactNode, useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from 'react' +import { + type ReactNode, + Suspense, + useEffect, + useEffectEvent, + useLayoutEffect, + useRef, + useState, +} from 'react' import { IconButton } from '@sim/emcn/button' import { useCompactViewport } from '@sim/emcn/layout/use-compact-viewport' import { OverflowText } from '@sim/emcn/overflow-text' @@ -246,20 +254,26 @@ export function PlatformShell({ data, banner, children }: PlatformShellProps) { : undefined } > - {location && !workspaceChat ? ( -
- {children} -
- ) : scrollingPage ? ( - -
{children}
-
- ) : ( - children - )} + {/* Unkeyed, so a navigation keeps the current page until the next one is ready. */} + + {location && !workspaceChat ? ( +
+ {children} +
+ ) : scrollingPage ? ( + +
{children}
+
+ ) : ( + children + )} +
)} - {state.status === 'loading' && } {state.status === 'error' && ( - {state.status === 'loading' && } {state.status === 'error' && ( onDayChange(1)} /> - {calendar.status === 'loading' && } {calendar.status === 'error' && ( } /> )} - {calendar.status === 'ready' && ( + {calendar.status !== 'error' && (
({ icon={icon} actions={} > - {state.status === 'loading' && } {state.status === 'not_offered' && ( - -
- -
-
-
-
- - - -
-
-
- - - -
- - {requestMode === 'assistant' &&
} -
-
- {requestMode === 'agent' && } - -
-
- - -
-
-
- -
- -
- ) -} diff --git a/apps/sim/platform/app/(shell)/home/_components/home-dashboard/home-dashboard.module.css b/apps/sim/platform/app/(shell)/home/_components/home-dashboard/home-dashboard.module.css index 4f7fe7a4076..522d29fe3ea 100644 --- a/apps/sim/platform/app/(shell)/home/_components/home-dashboard/home-dashboard.module.css +++ b/apps/sim/platform/app/(shell)/home/_components/home-dashboard/home-dashboard.module.css @@ -93,30 +93,3 @@ .column:empty { display: none; } -.placeholderGreeting, -.placeholderWordmark, -.placeholderComposer, -.placeholderGetStarted { - border-radius: var(--ui-control-radius); - background: var(--control-muted-fill); -} -.placeholderGreeting { - block-size: var(--spacing-56); - inline-size: 60%; -} -.placeholderWordmark { - block-size: var(--spacing-28); - inline-size: var(--spacing-40); -} -.placeholderGetStarted { - block-size: var(--spacing-132); -} -/* The real composer's first-paint heights: the Assistant's pill, the agent's field and toolbar. */ -.placeholderComposer { - block-size: 2.875rem; - border-radius: var(--radius-full); -} -.placeholderComposer[data-mode="agent"] { - block-size: 6.5rem; - border-radius: var(--ui-control-radius); -} diff --git a/apps/sim/platform/app/(shell)/home/_components/home-dashboard/home-dashboard.tsx b/apps/sim/platform/app/(shell)/home/_components/home-dashboard/home-dashboard.tsx index 2d9c09c7238..49c99d1e570 100644 --- a/apps/sim/platform/app/(shell)/home/_components/home-dashboard/home-dashboard.tsx +++ b/apps/sim/platform/app/(shell)/home/_components/home-dashboard/home-dashboard.tsx @@ -20,7 +20,6 @@ import { GetStarted } from '@/platform/app/(shell)/home/_components/get-started/ import styles from '@/platform/app/(shell)/home/_components/home-dashboard/home-dashboard.module.css' import type { HomeActions, HomeView } from '@/platform/app/(shell)/home/_components/model' import { RetryButton } from '@/platform/app/(shell)/home/_components/retry-button/retry-button' -import { SkeletonLane } from '@/platform/app/(shell)/home/_components/widget-skeleton/widget-skeleton' const WIDGETS: readonly HomeWidget[] = ['Activity', 'Email', 'Calendar'] const ACTIVITY_METRIC_TITLES: Record = { Build: 'Tasks run', Search: 'Searches' } @@ -114,7 +113,6 @@ export function HomeDashboard({
- {view.recents.status === 'loading' && } {view.recents.status === 'error' && ( : + return useHomeHydrated() ? : null } diff --git a/apps/sim/platform/app/(shell)/home/_components/widget-skeleton/widget-skeleton.module.css b/apps/sim/platform/app/(shell)/home/_components/widget-skeleton/widget-skeleton.module.css deleted file mode 100644 index c6e52ab6165..00000000000 --- a/apps/sim/platform/app/(shell)/home/_components/widget-skeleton/widget-skeleton.module.css +++ /dev/null @@ -1,54 +0,0 @@ -.rows { - display: grid; - gap: var(--ui-list-gap); - min-inline-size: 0; -} -.row { - display: flex; - align-items: start; - gap: var(--ui-row-gap); - padding: var(--ui-row-block) 0; -} -.glyph { - flex: none; - inline-size: var(--ui-icon-size); - block-size: var(--ui-icon-size); - border-radius: var(--radius-full); - background: var(--control-muted-fill); -} -.copy { - display: grid; - flex: 1; - gap: var(--ui-stack-gap); - min-inline-size: 0; -} -.title, -.detail { - display: block; - block-size: 1lh; - border-radius: var(--ui-control-radius); - background: var(--control-muted-fill); -} -.title { - inline-size: 40%; -} -.detail { - inline-size: 80%; -} -.block { - block-size: var(--ui-widget-max-size); - border-radius: var(--ui-control-radius); - background: var(--control-muted-fill); -} -.lane { - display: flex; - gap: var(--ui-list-gap); - block-size: var(--spacing-30); - margin-block-start: var(--spacing-28); - margin-inline: calc(-1 * var(--ui-row-inset)); -} -.lane i { - inline-size: var(--spacing-132); - border-radius: var(--ui-control-radius); - background: var(--control-muted-fill); -} diff --git a/apps/sim/platform/app/(shell)/home/_components/widget-skeleton/widget-skeleton.tsx b/apps/sim/platform/app/(shell)/home/_components/widget-skeleton/widget-skeleton.tsx deleted file mode 100644 index 4d1516a8f31..00000000000 --- a/apps/sim/platform/app/(shell)/home/_components/widget-skeleton/widget-skeleton.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import styles from '@/platform/app/(shell)/home/_components/widget-skeleton/widget-skeleton.module.css' - -const ROW_COUNT = 5 - -interface WidgetSkeletonProps { - label: string -} - -/** Holds a widget's list at its final size, so the page does not move when the data arrives. */ -export function WidgetSkeleton({ label }: WidgetSkeletonProps) { - return ( -
- {Array.from({ length: ROW_COUNT }, (_, index) => ( - - - - - - - - ))} -
- ) -} - -/** The same fill for a chart or schedule, whose shape has no rows. */ -export function SkeletonBlock({ label }: { label: string }) { - return
-} - -/** A recents lane holding its height until the history read settles. */ -export function SkeletonLane({ label }: { label: string }) { - return ( -
- - - -
- ) -} diff --git a/apps/sim/platform/app/(shell)/home/loading.tsx b/apps/sim/platform/app/(shell)/home/loading.tsx deleted file mode 100644 index 2901b9ec4ef..00000000000 --- a/apps/sim/platform/app/(shell)/home/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { HomeDashboardSkeleton } from '@/platform/app/(shell)/home/_components/home-dashboard/home-dashboard-skeleton' - -export default function Loading() { - return -} diff --git a/apps/sim/platform/app/(shell)/integrations/_components/credential-list/credential-list.tsx b/apps/sim/platform/app/(shell)/integrations/_components/credential-list/credential-list.tsx index 85163aa7630..3d3baee6c04 100644 --- a/apps/sim/platform/app/(shell)/integrations/_components/credential-list/credential-list.tsx +++ b/apps/sim/platform/app/(shell)/integrations/_components/credential-list/credential-list.tsx @@ -6,11 +6,16 @@ import { credentialDescription } from '@/platform/lib/integrations/credential-di import type { IntegrationCredential } from '@/platform/lib/integrations/credential-model' interface CredentialListProps { + /** + * The app whose page lists these credentials. Every row wears its mark, even a shared Google + * credential first linked to another app. + */ + providerId: string credentials: readonly IntegrationCredential[] onOpen: (credential: IntegrationCredential) => void } -export function CredentialList({ credentials, onOpen }: CredentialListProps) { +export function CredentialList({ providerId, credentials, onOpen }: CredentialListProps) { return (

@@ -21,7 +26,7 @@ export function CredentialList({ credentials, onOpen }: CredentialListProps) { {credentials.map((credential) => ( } + leading={} title={credential.displayName} detail={credentialDescription(credential)} onOpen={() => onOpen(credential)} diff --git a/apps/sim/platform/app/(shell)/integrations/_components/fixtures/integration-details/integration-details.tsx b/apps/sim/platform/app/(shell)/integrations/_components/fixtures/integration-details/integration-details.tsx index 28abada1c20..c5c52152379 100644 --- a/apps/sim/platform/app/(shell)/integrations/_components/fixtures/integration-details/integration-details.tsx +++ b/apps/sim/platform/app/(shell)/integrations/_components/fixtures/integration-details/integration-details.tsx @@ -1,12 +1,13 @@ 'use client' -import { useState, useTransition } from 'react' +import { type ComponentProps, useState, useTransition } from 'react' import { Button } from '@sim/emcn/button' import { ActionMenu } from '@sim/emcn/menu/action-menu' import { Modal } from '@sim/emcn/modal' import { generateId } from '@sim/utils/id' import Link from 'next/link' import { useQueryStates } from 'nuqs' +import { DirectoryBack } from '@/platform/app/(shell)/_components/resource-directory/resource-directory' import { CredentialConnect } from '@/platform/app/(shell)/integrations/_components/credential-connect/credential-connect' import type { CredentialDraft } from '@/platform/app/(shell)/integrations/_components/credential-connect/types' import { CredentialDetail } from '@/platform/app/(shell)/integrations/_components/credential-detail/credential-detail' @@ -41,6 +42,11 @@ import { interface IntegrationDetailsProps { integration: OrganizationIntegration + /** + * Where Back leads from each view. This component decides which credential is open, so it also + * names the way back, and the label can never describe a view the page is no longer showing. + */ + backHrefs: { integrations: string; credentials: string; app: string } onChange: (next: OrganizationIntegration) => void knownCredentials: readonly IntegrationCredential[] organization: { id: string; name: string } @@ -60,6 +66,7 @@ interface IntegrationDetailsProps { export function IntegrationDetails({ integration, + backHrefs, onChange, knownCredentials, organization, @@ -89,6 +96,21 @@ export function IntegrationDetails({ const selected = isOpening ? undefined : integration.credentials.find((credential) => credential.id === view.credential) + const back: ComponentProps = !(selectedSecret || selected) + ? { href: backHrefs.integrations, label: 'Integrations' } + : view.credentialFrom === 'credentials' + ? { href: backHrefs.credentials, label: 'Back to Credentials' } + : { + href: backHrefs.app, + label: `Back to ${integration.name}`, + onNavigate: (event) => { + event.preventDefault() + void setView( + { ...clearCredentialView, credential: null }, + { history: 'push', scroll: false } + ) + }, + } const connections = catalog?.connections ?? [] const hasOAuth = connections.some( (connection) => connection.setup === 'oauth' || connection.setup === 'mcp-oauth' @@ -242,6 +264,7 @@ export function IntegrationDetails({ } return ( <> + {selectedSecret ? ( diff --git a/apps/sim/platform/app/(shell)/integrations/_components/fixtures/integrations-page/integrations-page.tsx b/apps/sim/platform/app/(shell)/integrations/_components/fixtures/integrations-page/integrations-page.tsx index fce14e56cb0..5db381c6ff3 100644 --- a/apps/sim/platform/app/(shell)/integrations/_components/fixtures/integrations-page/integrations-page.tsx +++ b/apps/sim/platform/app/(shell)/integrations/_components/fixtures/integrations-page/integrations-page.tsx @@ -1,7 +1,6 @@ 'use client' import { useSearchParams } from 'next/navigation' -import { useQueryStates } from 'nuqs' import { NuqsAdapter } from 'nuqs/adapters/next/app' import { useIntegrationCenterResources } from '@/platform/app/(shell)/_components/session/integration-center-changes' import { useOrganizationIntegrations } from '@/platform/app/(shell)/_components/session/integration-changes' @@ -21,7 +20,6 @@ import { } from '@/platform/lib/integrations/credential-display' import { clearCredentialView, - credentialParams, serializeCredential, } from '@/platform/lib/integrations/credential-navigation' import { @@ -46,22 +44,11 @@ export function IntegrationsPage({ data, center, section, catalog }: Integration function IntegrationsContent({ data, center, section, catalog }: IntegrationsPageProps) { const search = useSearchParams() - const [, setCredentialView] = useQueryStates(credentialParams) const { integrations, save } = useOrganizationIntegrations(data) const library = useIntegrationCenterResources(center) const item = integrations.find((entry) => entry.providerId === section) const secrets = section === 'secrets' ? data?.genericSecrets : null const manualCredentials = library.resources.filter((resource) => resource.kind === 'credential') - const editingResource = - library.resources.some((resource) => resource.id === search.get('resource')) || - (search.get('resource') === 'new' && ['skills', 'mcps'].includes(search.get('tab') ?? '')) - const selected = - item && - search.get('credential') && - (item.credentials.some((credential) => credential.id === search.get('credential')) || - manualCredentials.some( - (resource) => resource.appId === item.providerId && resource.id === search.get('credential') - )) const credentialLinks: AppSummary[] = [ ...new Map( integrations.flatMap((integration) => @@ -91,35 +78,9 @@ function IntegrationsContent({ data, center, section, catalog }: IntegrationsPag return ( { - event.preventDefault() - void setCredentialView( - { ...clearCredentialView, credential: null }, - { history: 'push', scroll: false } - ) - }, - } - : item || secrets - ? { href: memberIntegrationHref('/integrations', search), label: 'Integrations' } - : undefined + (item && !center) || secrets + ? { href: memberIntegrationHref('/integrations', search), label: 'Integrations' } + : undefined } > {item || secrets ? ( @@ -128,6 +89,20 @@ function IntegrationsContent({ data, center, section, catalog }: IntegrationsPag entry.credentials)} organization={center.organization} @@ -153,60 +128,57 @@ function IntegrationsContent({ data, center, section, catalog }: IntegrationsPag )} ) : ( - <> - {!editingResource && } - ({ - id: integration.providerId, - name: integration.name, - description: integration.description, - category: integration.category ?? 'Other', - href: memberIntegrationHref(`/integrations/${integration.providerId}`, search), - connected: - integration.credentials.length > 0 || - manualCredentials.some((credential) => credential.appId === integration.providerId), - credentialCount: - integration.credentials.length + - manualCredentials.filter( - (credential) => credential.appId === integration.providerId - ).length, - searchable: integration.credentials - .map((credential) => `${credential.displayName} ${credential.accountLabel ?? ''}`) - .join(' '), - people: center?.appPeople[integration.providerId], - peopleTotal: center?.appUsageTotal[integration.providerId], - }))} - library={ - center - ? { - data: { ...center, resources: library.resources }, - save: library.save, - remove: library.remove, - leave: library.leave, - } - : undefined - } - credentials={[ - ...credentialLinks, - ...(data?.genericSecrets - ? [ - { - id: 'secrets', - name: data.genericSecrets.name, - description: data.genericSecrets.description, - category: 'Credentials', - scope: - data.genericSecrets.mode === 'organization' - ? ('shared' as const) - : ('personal' as const), - connected: true, - href: memberIntegrationHref('/integrations/secrets', search), - }, - ] - : []), - ]} - /> - + } + apps={integrations.map((integration) => ({ + id: integration.providerId, + name: integration.name, + description: integration.description, + category: integration.category ?? 'Other', + href: memberIntegrationHref(`/integrations/${integration.providerId}`, search), + connected: + integration.credentials.length > 0 || + manualCredentials.some((credential) => credential.appId === integration.providerId), + credentialCount: + integration.credentials.length + + manualCredentials.filter((credential) => credential.appId === integration.providerId) + .length, + searchable: integration.credentials + .map((credential) => `${credential.displayName} ${credential.accountLabel ?? ''}`) + .join(' '), + people: center?.appPeople[integration.providerId], + peopleTotal: center?.appUsageTotal[integration.providerId], + }))} + library={ + center + ? { + data: { ...center, resources: library.resources }, + save: library.save, + remove: library.remove, + leave: library.leave, + } + : undefined + } + credentials={[ + ...credentialLinks, + ...(data?.genericSecrets + ? [ + { + id: 'secrets', + name: data.genericSecrets.name, + description: data.genericSecrets.description, + category: 'Credentials', + scope: + data.genericSecrets.mode === 'organization' + ? ('shared' as const) + : ('personal' as const), + connected: true, + href: memberIntegrationHref('/integrations/secrets', search), + }, + ] + : []), + ]} + /> )} ) diff --git a/apps/sim/platform/app/(shell)/integrations/_components/integration-center/integration-center.tsx b/apps/sim/platform/app/(shell)/integrations/_components/integration-center/integration-center.tsx index bce7eaeea2c..714e1c04700 100644 --- a/apps/sim/platform/app/(shell)/integrations/_components/integration-center/integration-center.tsx +++ b/apps/sim/platform/app/(shell)/integrations/_components/integration-center/integration-center.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useTransition } from 'react' +import { type ReactNode, useState, useTransition } from 'react' import { Button } from '@sim/emcn/button' import { ActionMenu } from '@sim/emcn/menu/action-menu' import { ChoiceMenu } from '@sim/emcn/menu/choice-menu' @@ -45,6 +45,11 @@ interface ResourceLibrary { } interface IntegrationCenterProps { + /** + * The directory's page heading. A resource opened in place replaces the directory and this heading + * in the same render, so the two never disagree about which view the URL shows. + */ + heading?: ReactNode apps: readonly AppSummary[] credentials?: readonly AppSummary[] library?: ResourceLibrary @@ -60,6 +65,7 @@ const CREATE_LABEL = { /** The same directory receives authorized live summaries or server-gated example data. */ export function IntegrationCenter({ + heading, apps, credentials = [], library, @@ -214,164 +220,167 @@ export function IntegrationCenter({ } return ( -
-
- - void setView({ q })} - /> - } - > - {current === 'apps' ? ( - + {heading} +
+
+ + void setView({ q })} + /> + } + > + {current === 'apps' ? ( + ({ + label: category, + value: `category:${category}`, + })), + }, + ]} + onValueChange={(value) => { + if (value.startsWith('category:')) { + void setView({ category: value.slice(9), connection: 'all' }) + } else if (value === 'all' || value === 'connected' || value === 'available') { + void setView({ category: 'all', connection: value }) + } + }} + /> + ) : ( + { + if (scope === 'all' || scope === 'shared' || scope === 'personal') + void setView({ scope }) + }} + /> + )} + ({ - label: category, - value: `category:${category}`, - })), + id: 'asc', + label: 'Name A–Z', + checked: view.order === 'asc', + onSelect: () => void setView({ order: 'asc' }), + }, + { + id: 'desc', + label: 'Name Z–A', + checked: view.order === 'desc', + onSelect: () => void setView({ order: 'desc' }), }, ]} - onValueChange={(value) => { - if (value.startsWith('category:')) { - void setView({ category: value.slice(9), connection: 'all' }) - } else if (value === 'all' || value === 'connected' || value === 'available') { - void setView({ category: 'all', connection: value }) - } - }} /> + {current !== 'apps' && library && ( + + )} + +
+
+ {current === 'apps' ? ( + <> + app.connected)} + onNavigate={navigate} + /> + !app.connected)} + onNavigate={navigate} + /> + ) : ( - { - if (scope === 'all' || scope === 'shared' || scope === 'personal') - void setView({ scope }) - }} - /> + <> + {(['shared', 'personal'] as const).map((scope) => ( + resource.scope === scope)} + links={ + current === 'credentials' + ? credentialLinks.filter((item) => item.scope === scope) + : [] + } + order={view.order} + onNavigate={navigate} + onOpen={open} + resourceHref={(resource) => + serializeCredential( + serializeCenter(base, { resource: resource.id, resourceTab: null }), + clearCredentialView + ) + } + /> + ))} + )} - void setView({ order: 'asc' }), - }, - { - id: 'desc', - label: 'Name Z–A', - checked: view.order === 'desc', - onSelect: () => void setView({ order: 'desc' }), - }, - ]} - /> - {current !== 'apps' && library && ( - + {settled && empty && ( +
+

+ {term ? 'No results found' : `No ${current === 'mcps' ? 'MCPs' : current} yet`} +

+

+ {term + ? 'Try a different name or clear your filters.' + : current === 'apps' + ? 'Apps available to your organization appear here.' + : 'Your personal and shared connections appear here.'} +

+
)} - -
-
- {current === 'apps' ? ( - <> - app.connected)} - onNavigate={navigate} - /> - !app.connected)} - onNavigate={navigate} - /> - - ) : ( - <> - {(['shared', 'personal'] as const).map((scope) => ( - resource.scope === scope)} - links={ - current === 'credentials' - ? credentialLinks.filter((item) => item.scope === scope) - : [] - } - order={view.order} - onNavigate={navigate} - onOpen={open} - resourceHref={(resource) => - serializeCredential( - serializeCenter(base, { resource: resource.id, resourceTab: null }), - clearCredentialView - ) - } - /> - ))} - - )} - {settled && empty && ( -
-

- {term ? 'No results found' : `No ${current === 'mcps' ? 'MCPs' : current} yet`} -

-

- {term - ? 'Try a different name or clear your filters.' - : current === 'apps' - ? 'Apps available to your organization appear here.' - : 'Your personal and shared connections appear here.'} -

-
+
+ {creating === 'credential' && library && ( + setCreating(null)} + onSave={library.save} + /> )}
- {creating === 'credential' && library && ( - setCreating(null)} - onSave={library.save} - /> - )} -
+ ) } diff --git a/apps/sim/platform/app/(shell)/integrations/_components/integrations-page/integrations-route.tsx b/apps/sim/platform/app/(shell)/integrations/_components/integrations-page/integrations-route.tsx index 1724fc0d583..2fae5f69345 100644 --- a/apps/sim/platform/app/(shell)/integrations/_components/integrations-page/integrations-route.tsx +++ b/apps/sim/platform/app/(shell)/integrations/_components/integrations-page/integrations-route.tsx @@ -2,7 +2,6 @@ import type { ReactNode } from 'react' import { EmptyState } from '@sim/emcn/empty-state' -import { InlineNotice } from '@sim/emcn/status' import { useSearchParams } from 'next/navigation' import { IntegrationDetails } from '@/platform/app/(shell)/integrations/_components/integration-details/integration-details' import { IntegrationList } from '@/platform/app/(shell)/integrations/_components/integration-list/integration-list' @@ -23,9 +22,6 @@ import { import { integrationCenterHref } from '@/platform/lib/integrations/center-navigation' import { GENERIC_SECRETS_SEGMENT } from '@/platform/shared/integrations' -const LOADING_CONNECTIONS = 'Loading your connections' -const LOADING_CONNECTION = 'Loading connection' - export interface IntegrationsRouteProps { organizationId: string /** The `/integrations/[provider]` segment; absent on the index. */ @@ -64,10 +60,8 @@ export function IntegrationsRoute({ { q: search } ) - const inventoryStatus = inventory.error ? ( + const inventoryFailure = inventory.error ? ( - ) : !inventory.data ? ( - ) : null const readFailures = ( <> @@ -85,22 +79,23 @@ export function IntegrationsRoute({ {connectionRequest} - {inventoryStatus ?? ( - <> - {readFailures} - - - )} + {inventoryFailure ?? + (inventory.data && ( + <> + {readFailures} + + + ))} ) @@ -110,16 +105,12 @@ export function IntegrationsRoute({ ? { href: integrationHref(integration.id), label: `Back to ${integration.name}` } : { href: indexHref, label: 'Integrations' } const unavailable = (loading: boolean) => - loading ? ( - - ) : ( - - ) + loading ? null : if (section === GENERIC_SECRETS_SEGMENT) return ( - {inventoryStatus} + {inventoryFailure} {readFailures} {genericSecrets ? ( <> @@ -134,43 +125,44 @@ export function IntegrationsRoute({ return ( - {inventoryStatus ?? ( - <> - {readFailures} - {integration ? ( - - serialize(integrationCenterHref(pathOf(integration.id), params), { - q: search, - account: credentialId, - }) - } - pending={actions.pending} - onConnect={() => { - if (integration.connectionTarget) actions.connect(integration.connectionTarget) - }} - onReconnect={actions.reconnect} - onDisconnect={async (credentialId) => { - await actions.disconnect(credentialId) - if (account === credentialId) clearAccount() - }} - renderToolPermissions={(credentialId) => ( - - )} - /> - ) : ( - !policies.error && unavailable(policies.isPending) - )} - - )} + {inventoryFailure ?? + (inventory.data && ( + <> + {readFailures} + {integration ? ( + + serialize(integrationCenterHref(pathOf(integration.id), params), { + q: search, + account: credentialId, + }) + } + pending={actions.pending} + onConnect={() => { + if (integration.connectionTarget) actions.connect(integration.connectionTarget) + }} + onReconnect={actions.reconnect} + onDisconnect={async (credentialId) => { + await actions.disconnect(credentialId) + if (account === credentialId) clearAccount() + }} + renderToolPermissions={(credentialId) => ( + + )} + /> + ) : ( + !policies.error && unavailable(policies.isPending) + )} + + ))} ) } diff --git a/apps/sim/platform/app/(shell)/integrations/_components/tool-permissions/tool-permissions-panel.tsx b/apps/sim/platform/app/(shell)/integrations/_components/tool-permissions/tool-permissions-panel.tsx index edb88f87a35..6b1b1ffa2cf 100644 --- a/apps/sim/platform/app/(shell)/integrations/_components/tool-permissions/tool-permissions-panel.tsx +++ b/apps/sim/platform/app/(shell)/integrations/_components/tool-permissions/tool-permissions-panel.tsx @@ -1,6 +1,5 @@ 'use client' -import { InlineNotice } from '@sim/emcn/status' import { ProviderHeading } from '@/platform/app/(shell)/integrations/_components/provider-heading/provider-heading' import { QueryFailure } from '@/platform/app/(shell)/integrations/_components/query-failure/query-failure' import { ToolPermissions } from '@/platform/app/(shell)/integrations/_components/tool-permissions/tool-permissions' @@ -21,7 +20,7 @@ export function ToolPermissionsPanel({ const { query, view, pending, save } = useToolPermissions(organizationId, credentialId) const { search, setSearch } = useToolPermissionsSearch() if (query.error) return - if (!view) return + if (!view) return null return ( <> {showHeading && ( diff --git a/apps/sim/platform/app/(shell)/loading.tsx b/apps/sim/platform/app/(shell)/loading.tsx deleted file mode 100644 index c577bce5f30..00000000000 --- a/apps/sim/platform/app/(shell)/loading.tsx +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Lets the shell paint while a page reads its data. Blank, so nothing moves when the page arrives; - * a route whose final geometry is known supplies its own skeleton in its own `loading.tsx`. - */ -export default function Loading() { - return null -} diff --git a/packages/emcn/src/catalog/icon-geometry.tsx b/packages/emcn/src/catalog/icon-geometry.tsx index dd96fb012d0..0668b34b135 100644 --- a/packages/emcn/src/catalog/icon-geometry.tsx +++ b/packages/emcn/src/catalog/icon-geometry.tsx @@ -788,7 +788,6 @@ export const glyphs = { }, I126: { label: 'MCP', - artwork: true, viewBox: '0 0 16 16', draw: (): ReactNode => ( <> @@ -798,6 +797,7 @@ export const glyphs = { clipRule='evenodd' d='M14.56 7.88L14.5 7.93L8.7 13.62C8.68 13.64 8.67 13.66 8.66 13.68C8.65 13.7 8.64 13.72 8.64 13.75C8.64 13.77 8.65 13.8 8.66 13.82C8.67 13.84 8.68 13.86 8.7 13.88L8.7 13.88L9.89 15.05C9.99 15.15 10.05 15.29 10.06 15.44C10.06 15.58 10 15.72 9.9 15.83L9.89 15.84C9.78 15.94 9.64 16 9.49 16C9.34 16 9.2 15.94 9.09 15.84L7.9 14.67C7.77 14.55 7.68 14.41 7.61 14.25C7.54 14.09 7.51 13.92 7.51 13.75C7.51 13.58 7.54 13.41 7.61 13.25C7.68 13.1 7.77 12.95 7.9 12.83L13.7 7.14C14.01 6.84 14.19 6.42 14.2 5.98C14.2 5.55 14.04 5.13 13.73 4.81L13.7 4.78L13.67 4.75C13.35 4.44 12.91 4.26 12.47 4.26C12.02 4.26 11.58 4.44 11.26 4.75L6.48 9.44H6.48L6.41 9.5C6.31 9.61 6.16 9.67 6.01 9.67C5.86 9.67 5.72 9.61 5.61 9.5C5.51 9.4 5.45 9.26 5.45 9.12C5.44 8.97 5.5 8.83 5.6 8.73L5.61 8.72L10.46 3.96C11.11 3.32 11.12 2.28 10.49 1.63L10.46 1.6C10.14 1.29 9.7 1.11 9.25 1.11C8.81 1.11 8.37 1.29 8.05 1.6L1.64 7.9C1.53 8 1.38 8.06 1.23 8.06C1.08 8.06 0.94 8 0.83 7.9C0.73 7.79 0.67 7.66 0.67 7.51C0.67 7.36 0.72 7.22 0.82 7.12L0.83 7.11L7.25 0.81C7.79 0.29 8.51 0 9.26 0C10 0 10.72 0.29 11.26 0.81C11.89 1.43 12.19 2.3 12.06 3.17C12.94 3.05 13.83 3.34 14.47 3.96L14.5 3.99C14.76 4.25 14.97 4.54 15.11 4.88C15.25 5.21 15.33 5.56 15.33 5.92C15.34 6.28 15.27 6.64 15.14 6.98C15.01 7.31 14.81 7.62 14.56 7.87M12.87 6.32C12.97 6.22 13.03 6.08 13.03 5.94C13.03 5.79 12.98 5.65 12.88 5.55L12.87 5.54C12.76 5.43 12.61 5.37 12.46 5.37C12.31 5.37 12.17 5.43 12.06 5.54L7.32 10.19C7 10.5 6.56 10.68 6.11 10.68C5.66 10.68 5.23 10.5 4.91 10.19C4.76 10.04 4.63 9.86 4.55 9.66C4.46 9.46 4.42 9.25 4.41 9.03C4.41 8.81 4.45 8.6 4.53 8.4C4.61 8.2 4.73 8.01 4.88 7.86L4.91 7.83L9.66 3.17C9.76 3.07 9.82 2.93 9.82 2.79C9.83 2.64 9.77 2.5 9.67 2.4L9.66 2.39C9.55 2.28 9.41 2.22 9.26 2.22C9.11 2.22 8.96 2.28 8.86 2.39L4.11 7.04C3.85 7.3 3.64 7.6 3.49 7.94C3.35 8.28 3.28 8.64 3.28 9.01C3.28 9.38 3.35 9.74 3.49 10.08C3.64 10.41 3.85 10.72 4.11 10.98C4.65 11.5 5.36 11.79 6.11 11.79C6.86 11.79 7.58 11.5 8.12 10.98L12.87 6.32Z' fill='currentColor' + stroke='none' /> diff --git a/scripts/check-platform-convergence.baseline.json b/scripts/check-platform-convergence.baseline.json index ddaea3373e6..2909534cb20 100644 --- a/scripts/check-platform-convergence.baseline.json +++ b/scripts/check-platform-convergence.baseline.json @@ -27,12 +27,9 @@ "app/(shell)/home/_components/calendar/calendar-day.tsx", "app/(shell)/home/_components/connected-widget/connected-widget.module.css", "app/(shell)/home/_components/connected-widget/connected-widget.tsx", - "app/(shell)/home/_components/home-dashboard/home-dashboard-skeleton.tsx", "app/(shell)/home/_components/home-dashboard/home-dashboard.module.css", "app/(shell)/home/_components/home-dashboard/home-dashboard.tsx", "app/(shell)/home/_components/live-home/live-home.tsx", - "app/(shell)/home/_components/widget-skeleton/widget-skeleton.module.css", - "app/(shell)/home/_components/widget-skeleton/widget-skeleton.tsx", "app/(shell)/integrations/_components/connected-credential/connected-credential.module.css", "app/(shell)/integrations/_components/connected-credential/connected-credential.tsx", "app/(shell)/integrations/_components/connection-access/connection-access.tsx",