Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ jobs:
- name: Bare-icon theme-safety audit
run: bun run check:bare-icons

- name: Icon SVG path validity audit
run: bun run check:icon-paths

- name: Verify realtime prune graph
run: bun run check:realtime-prune

Expand Down
62 changes: 31 additions & 31 deletions apps/docs/components/icons.tsx

Large diffs are not rendered by default.

15 changes: 4 additions & 11 deletions apps/sim/app/workspace/[workspaceId]/components/error/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,15 @@ interface ErrorShellProps {
title: string
description: string
icon?: ReactNode
digest?: string
children: ReactNode
}

/**
* Centered layout shared by the workspace error boundary and not-found page.
* Renders a framed glyph, serif headline, supporting paragraph, optional
* digest pill, and a row of action buttons.
* Renders a framed glyph, serif headline, supporting paragraph, and a row of
* action buttons.
*/
export function ErrorShell({ title, description, icon, digest, children }: ErrorShellProps) {
export function ErrorShell({ title, description, icon, children }: ErrorShellProps) {
return (
<div className='flex h-full flex-1 items-center justify-center bg-[var(--bg)] px-6 py-12'>
<div className='flex w-full max-w-[420px] flex-col items-center gap-5 text-center'>
Expand All @@ -51,12 +50,6 @@ export function ErrorShell({ title, description, icon, digest, children }: Error
{description}
</p>
</div>
{digest && (
<span className='inline-flex max-w-full items-center gap-1.5 rounded-full border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 py-1 font-mono text-[11px]'>
<span className='text-[var(--text-muted)]'>digest</span>
<span className='truncate text-[var(--text-body)]'>{digest}</span>
</span>
)}
<div className='flex flex-wrap items-center justify-center gap-2 pt-1'>{children}</div>
</div>
</div>
Expand Down Expand Up @@ -85,7 +78,7 @@ export function ErrorState({
}, [error.message, error.digest, loggerName])

return (
<ErrorShell title={title} description={description} icon={icon} digest={error.digest}>
<ErrorShell title={title} description={description} icon={icon}>
{children}
<Button variant='primary' size='md' onClick={reset}>
Refresh
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ConnectServiceAccountModal } from '@/app/workspace/[workspaceId]/integr
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
import {
Expand All @@ -44,6 +45,7 @@ interface IntegrationBlockDetailProps {
}

export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null)
useOAuthReturnRouter()
const router = useRouter()
const [connectMode, setConnectMode] = useQueryState(connectParam.key, connectParam.parser)
Expand All @@ -53,11 +55,13 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
const oauthService = resolveOAuthServiceForIntegration(integration)
const [oauthOpen, setOAuthOpen] = useState(false)

const { data: credentials = [] } = useWorkspaceCredentials({
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
workspaceId,
enabled: Boolean(workspaceId),
})

useScrollRestoration(scrollContainerRef, { ready: !credentialsLoading })

const connectedCredentials = useMemo(() => {
if (!oauthService) return []
return credentials.filter(
Expand Down Expand Up @@ -170,7 +174,10 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
serviceIcon={oauthService.serviceIcon}
/>
)}
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'
>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>
<div className='flex flex-col gap-3'>
{Icon ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
'use client'

import { type RefObject, useEffect, useLayoutEffect, useRef } from 'react'
import { usePathname } from 'next/navigation'

/** Namespace prefix so restoration keys never collide with other tab state. */
const STORAGE_PREFIX = 'integrations-scroll:' as const

/**
* True when the most recent navigation was a Back/Forward history traversal.
*
* A single module-level `popstate` listener flips this before the destination
* page mounts; each restore reads it once and clears it. Fresh push navigations
* (a sidebar link, a typed URL) never fire `popstate`, so the flag stays false
* and those visits open at the top instead of jumping to a stale position.
* Registered at module load — a per-hook listener would attach too late to see
* the `popstate` that triggered its own mount.
*/
let lastNavWasTraversal = false
if (typeof window !== 'undefined') {
window.addEventListener('popstate', () => {
lastNavWasTraversal = true
})
}
Comment thread
waleedlatif1 marked this conversation as resolved.

interface UseScrollRestorationOptions {
/**
* Flag that flips to `true` once the async content the scroll height depends
* on has settled (e.g. the credentials query is no longer pending). A late
* restore is re-attempted when this transitions so we do not clamp against a
* too-short container while data is still loading.
*/
ready?: boolean
}

/**
* Restores the scroll position of an inner scroll container across browser
* Back/Forward navigation within the same tab.
*
* Next.js App Router only restores WINDOW scroll, so a page whose content
* scrolls inside a nested `overflow-y-auto` element loses its position on Back.
* This hook persists `scrollTop` in `sessionStorage` (per-pathname, tab-scoped)
* and re-applies it — only on history traversals — once the container has laid
* out.
*
* Programmatic vs. user scrolls are told apart by VALUE, not a one-shot event
* flag: a restore assigns `scrollTop === lastAppliedRef`, so the echoed scroll
* event compares equal and is ignored (it is never persisted, so a clamped
* value cannot overwrite the saved target). This avoids the race where the
* programmatic scroll event fires before the listener attaches and a stuck flag
* drops the user's first real scroll. The restore itself latches only on a full
* (non-clamped) apply, so a late `ready` retry can complete a position that was
* clamped against still-loading content, and it stops the moment the user
* scrolls away from the last applied value so it never fights them.
*
* @param containerRef Ref to the scrollable container element.
* @param options `ready` marks async content as settled for a late retry.
*/
export function useScrollRestoration(
containerRef: RefObject<HTMLDivElement | null>,
{ ready = true }: UseScrollRestorationOptions = {}
): void {
const pathname = usePathname()
const storageKey = `${STORAGE_PREFIX}${pathname}`

const storageKeyRef = useRef(storageKey)
const hasRestoredRef = useRef(false)
/** Last `scrollTop` this hook assigned, so its echo scroll event is ignored. */
const lastAppliedRef = useRef(-1)
/** Latest user-initiated `scrollTop`, flushed to storage on unmount. */
const latestUserScrollRef = useRef<number | null>(null)
const rafRef = useRef<number | null>(null)
/** Captured once per mount: did we arrive here via Back/Forward? */
const shouldRestoreRef = useRef<boolean | null>(null)

useEffect(() => {
storageKeyRef.current = storageKey
}, [storageKey])

useEffect(() => {
const el = containerRef.current
if (!el) return

const persist = (value: number) => {
try {
sessionStorage.setItem(storageKeyRef.current, String(value))
} catch {}
}

const onScroll = () => {
if (el.scrollTop === lastAppliedRef.current) return
latestUserScrollRef.current = el.scrollTop
if (rafRef.current !== null) return
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null
persist(el.scrollTop)
})
}

el.addEventListener('scroll', onScroll, { passive: true })

return () => {
el.removeEventListener('scroll', onScroll)
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
if (latestUserScrollRef.current !== null) persist(latestUserScrollRef.current)
}
}, [containerRef])

useLayoutEffect(() => {
const el = containerRef.current
if (!el || hasRestoredRef.current) return

if (shouldRestoreRef.current === null) {
shouldRestoreRef.current = lastNavWasTraversal
lastNavWasTraversal = false
}
if (!shouldRestoreRef.current) {
hasRestoredRef.current = true
return
}

if (lastAppliedRef.current !== -1 && el.scrollTop !== lastAppliedRef.current) {
hasRestoredRef.current = true
return
}

let target = 0
try {
const raw = sessionStorage.getItem(storageKeyRef.current)
target = raw ? Number(raw) : 0
} catch {}
if (!Number.isFinite(target) || target <= 0) {
hasRestoredRef.current = true
return
}

const maxScroll = el.scrollHeight - el.clientHeight
if (maxScroll <= 0) return

lastAppliedRef.current = Math.min(target, maxScroll)
el.scrollTop = lastAppliedRef.current
if (maxScroll >= target) hasRestoredRef.current = true
}, [containerRef, ready])
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { type ComponentType, useCallback, useMemo } from 'react'
import { type ComponentType, useCallback, useMemo, useRef } from 'react'
import {
ArrowRight,
ChevronDown,
Expand All @@ -26,6 +26,7 @@ import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/c
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore'
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
import {
ALL_CATEGORY,
CONNECTED_LABEL,
Expand Down Expand Up @@ -135,6 +136,7 @@ function ConnectedItem({ href, blockType, name, description, icon: Icon }: Conne
}

export function Integrations() {
const scrollContainerRef = useRef<HTMLDivElement>(null)
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''

Expand Down Expand Up @@ -163,6 +165,8 @@ export function Integrations() {
enabled: Boolean(workspaceId),
})

useScrollRestoration(scrollContainerRef, { ready: !credentialsLoading })

const oauthCredentials = useMemo(
() => credentials.filter((c) => c.type === 'oauth' || c.type === 'service_account'),
[credentials]
Expand Down Expand Up @@ -289,7 +293,10 @@ export function Integrations() {
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<IntegrationTabsHeader active='integrations' workspaceId={workspaceId} />
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'
>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>
<ShowcaseWithExplore prompt='Explain the integrations in Sim and what I should connect.' />
<div className='flex items-center gap-2'>
Expand Down
Loading
Loading