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
21 changes: 18 additions & 3 deletions .claude/rules/sim-url-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Put state in the URL **only** when it is *all* of: shareable, deep-linkable, boo
## Anti-patterns (forbidden)

- Direct `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` to **read** state.
- Hand-built query strings + `router.replace`/`router.push` to **mutate** state.
- Hand-built query strings + `router.replace`/`router.push` to **mutate** state. **If the target path equals the current path, it is a query mutation, not a navigation** — even when written as a full path template. Re-serializing the path by hand is lossy by construction: it drops every param the template forgets. Use the nuqs setter (`setParams({ key: null }, { history: 'replace', scroll: false })`) — `null` always removes the key, and only the params you name are touched. Both options are already nuqs defaults (see "Conventions"); write them explicitly because a group whose shared options set `history: 'push'` (e.g. `filesUrlKeys`) would otherwise push a back-stack entry for a strip.
- `window.history.replaceState`/`pushState` to mutate a param.
- Duplicating URL state into a store and syncing it with effects / `popstate` listeners.
- High-frequency or large state in the URL (cursor, pan/zoom, un-debounced keystrokes, big JSON blobs).
Expand All @@ -44,7 +44,7 @@ These reads/mutations are **not** anti-patterns and stay as-is:

- **Outbound URL builders** — `new URLSearchParams({...})` to construct a `href`, a download endpoint, an external WebSocket/API URL, or a `window.open(_, '_blank')` destination.
- **Route navigations** — `router.push('/path/[id]?folderId=x')` that changes the route *path*, not just the current query. A nuqs setter only mutates the query on the current path; cross-path navigation stays on `router`.
- **Read-once auth / redirect signals** — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`.
- **Read-once auth / redirect signals** — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `new` (invite signup flow), `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`. Key names are per-surface: files' `new` is a genuine nuqs param (`files/search-params.ts`), while invite's `new` is a one-shot signup signal.

## Per-feature `search-params.ts` — single source of truth

Expand Down Expand Up @@ -128,7 +128,22 @@ If a client param must be re-read server-side after a change, set `shallow: fals

## Suspense boundary

`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `<Suspense>` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame — see `apps/sim/app/workspace/[workspaceId]/files/page.tsx`.
`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `<Suspense>` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame.

**Never `fallback={null}` on a page entry.** The route's co-located `loading.tsx` default export *is* the correct fallback — one skeleton serves both the route-level navigation transition (which Next renders automatically) and the in-page suspend (which this boundary renders). If the segment has no `loading.tsx`, add one; the route transition needs it anyway. Import it absolutely (`sim-imports.md`):

```typescript
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/loading'

<Suspense fallback={<KnowledgeBaseLoading />}>
<KnowledgeBase id={id} knowledgeBaseName={kbName || 'Knowledge Base'} />
</Suspense>
```

Reference: `apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx`.

This applies to **page entries**. An inner `<Suspense>` wrapping a `lazy()` component is the exception: there `fallback={null}` is correct, precisely so the suspend resolves at the nearest boundary instead of flashing the whole route — see `sim-imports.md`, "Code-splitting through barrels".

## Debounced text inputs

Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Suspense } from 'react'
import type { Metadata } from 'next'
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import LoginLoading from '@/app/(auth)/login/loading'
import LoginForm from '@/app/(auth)/login/login-form'

export const metadata: Metadata = {
Expand All @@ -15,7 +16,7 @@ export default async function LoginPage() {
await getOAuthProviderStatus()

return (
<Suspense fallback={null}>
<Suspense fallback={<LoginLoading />}>
<LoginForm
githubAvailable={githubAvailable}
googleAvailable={googleAvailable}
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/(auth)/sso/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Suspense } from 'react'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { isRegistrationDisabled, isSsoEnabled } from '@/lib/core/config/env-flags'
import SSOLoading from '@/app/(auth)/sso/loading'
import SSOForm from '@/ee/sso/components/sso-form'

export const metadata: Metadata = {
Expand All @@ -16,7 +17,7 @@ export default async function SSOPage() {
}

return (
<Suspense fallback={null}>
<Suspense fallback={<SSOLoading />}>
<SSOForm registrationDisabled={isRegistrationDisabled} />
</Suspense>
)
Expand Down
7 changes: 0 additions & 7 deletions apps/sim/app/(auth)/verify/use-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,20 +81,13 @@ export function useVerification({
const [email, setEmail] = useState('')
const [status, setStatus] = useState<VerificationStatus>('idle')
const [isResending, setIsResending] = useState(false)
const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false)
const [errorMessage, setErrorMessage] = useState('')

useEffect(() => {
const storedEmail = sessionStorage.getItem('verificationEmail')
if (storedEmail) setEmail(storedEmail)
}, [])

useEffect(() => {
if (email && !isSendingInitialOtp && hasEmailService) {
setIsSendingInitialOtp(true)
}
}, [email, isSendingInitialOtp, hasEmailService])

const isOtpComplete = otp.length === 6

async function verifyCode() {
Expand Down
16 changes: 5 additions & 11 deletions apps/sim/app/(auth)/verify/verify-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,15 @@ function VerificationForm({
const isInvalidOtp = status === 'error'

const [countdown, setCountdown] = useState(0)
const [isResendDisabled, setIsResendDisabled] = useState(false)

useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
return () => clearTimeout(timer)
}
if (countdown === 0 && isResendDisabled) {
setIsResendDisabled(false)
}
}, [countdown, isResendDisabled])
if (countdown <= 0) return
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
return () => clearTimeout(timer)
}, [countdown])

const handleResend = () => {
resendCode()
setIsResendDisabled(true)
setCountdown(30)
}

Expand Down Expand Up @@ -128,7 +122,7 @@ function VerificationForm({
Resend in <span className='text-[var(--text-primary)]'>{countdown}s</span>
</span>
) : (
<AuthTextLink onClick={handleResend} disabled={isLoading || isResendDisabled}>
<AuthTextLink onClick={handleResend} disabled={isLoading}>
Resend
</AuthTextLink>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,9 @@ export default function ResumeExecutionPage({
executionId,
selectedContextId ?? undefined
)
const [selectedStatus, setSelectedStatus] =
useState<PausePointWithQueue['resumeStatus']>('paused')
const [queuePosition, setQueuePosition] = useState<number | null | undefined>(undefined)
const selectedStatus: PausePointWithQueue['resumeStatus'] =
selectedDetail?.pausePoint.resumeStatus ?? 'paused'
const queuePosition = selectedDetail?.pausePoint.queuePosition
const resumeInputsRef = useRef<Record<string, string>>({})
const [resumeInput, setResumeInput] = useState('')
const [formValuesByContext, setFormValuesByContext] = useState<
Expand Down Expand Up @@ -440,10 +440,7 @@ export default function ResumeExecutionPage({
[]
)

const selectedOperation = useMemo(
() => selectedDetail?.pausePoint.response?.data?.operation || 'human',
[selectedDetail]
)
const selectedOperation = selectedDetail?.pausePoint.response?.data?.operation || 'human'
const isHumanMode = selectedOperation === 'human'

const inputFormatFields = useMemo(
Expand Down Expand Up @@ -524,8 +521,6 @@ export default function ResumeExecutionPage({

useEffect(() => {
if (!selectedDetail) return
setSelectedStatus(selectedDetail.pausePoint.resumeStatus)
setQueuePosition(selectedDetail.pausePoint.queuePosition)
seedFormFromDetail(selectedDetail)
}, [selectedDetail, seedFormFromDetail])

Expand Down Expand Up @@ -604,7 +599,6 @@ export default function ResumeExecutionPage({
})
if (!ok) {
setError(payload.error || 'Failed to resume execution.')
setSelectedStatus(selectedDetail.pausePoint.resumeStatus)
return
}
const nextStatus = payload.status === 'queued' ? 'queued' : 'resuming'
Expand Down Expand Up @@ -641,8 +635,6 @@ export default function ResumeExecutionPage({
}
}
)
setSelectedStatus(nextStatus)
setQueuePosition(nextQueuePosition)
setSelectedContextId((prev) => (prev !== selectedContextId ? prev : fallbackContextId))
setMessage(
payload.status === 'queued' ? 'Resume request queued.' : 'Resume started successfully.'
Expand Down
38 changes: 18 additions & 20 deletions apps/sim/app/invite/[id]/invite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,35 +278,33 @@ export default function Invite({ registrationDisabled }: InviteProps) {
const { data: session, isPending } = useSession()
const queryClient = useQueryClient()
const [actionError, setActionError] = useState<InviteError | null>(null)
const [urlError, setUrlError] = useState<InviteError | null>(null)
const [isAccepting, setIsAccepting] = useState(false)
const [accepted, setAccepted] = useState(false)
const [isNewUser, setIsNewUser] = useState(false)
const [token, setToken] = useState<string | null>(null)
/** `undefined` until the effect reads storage; `null` once read and empty. */
const [storedToken, setStoredToken] = useState<string | null | undefined>(undefined)

useEffect(() => {
const errorReason = searchParams.get('error')
const isNew = searchParams.get('new') === 'true'
setIsNewUser(isNew)
const isNewUser = searchParams.get('new') === 'true'
const errorReason = searchParams.get('error')
const urlError = errorReason ? getInviteError(errorReason) : null
const tokenFromQuery = searchParams.get('token')
/**
* Derived during render so the invitation query key is correct on the first
* commit; an effect-set token refetches under a second key whenever the
* session cache is already warm at mount.
*/
const token = tokenFromQuery ?? storedToken ?? null
const isTokenResolved = tokenFromQuery !== null || storedToken !== undefined

const tokenFromQuery = searchParams.get('token')
useEffect(() => {
if (tokenFromQuery) {
setToken(tokenFromQuery)
sessionStorage.setItem(inviteTokenStorageKey, tokenFromQuery)
} else {
const storedToken = sessionStorage.getItem(inviteTokenStorageKey)
if (storedToken) {
setToken(storedToken)
}
}

if (errorReason) {
setUrlError(getInviteError(errorReason))
return
}
}, [searchParams, inviteId, inviteTokenStorageKey])
setStoredToken(sessionStorage.getItem(inviteTokenStorageKey))
}, [tokenFromQuery, inviteTokenStorageKey])

const invitationQuery = useInvitationDetails(inviteId, token, session?.user?.id ?? null, {
enabled: Boolean(session?.user),
enabled: Boolean(session?.user) && isTokenResolved,
})
const invitation = invitationQuery.data?.invitation ?? null
const joinPreview = invitationQuery.data?.joinPreview ?? null
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/invite/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Suspense } from 'react'
import type { Metadata } from 'next'
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
import Invite from '@/app/invite/[id]/invite'
import InviteLoading from '@/app/invite/[id]/loading'

export const metadata: Metadata = {
title: 'Invite',
Expand All @@ -12,7 +13,7 @@ export const dynamic = 'force-dynamic'

export default function InvitePage() {
return (
<Suspense fallback={null}>
<Suspense fallback={<InviteLoading />}>
<Invite registrationDisabled={isRegistrationDisabled} />
</Suspense>
)
Expand Down
5 changes: 3 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { Files } from '../files'
import { Files } from '@/app/workspace/[workspaceId]/files/files'
import FilesLoading from '@/app/workspace/[workspaceId]/files/loading'

export const metadata: Metadata = {
title: 'Files',
Expand All @@ -9,7 +10,7 @@ export const metadata: Metadata = {

export default function FilesFilePage() {
return (
<Suspense fallback={null}>
<Suspense fallback={<FilesLoading />}>
<Files />
</Suspense>
)
Expand Down
8 changes: 2 additions & 6 deletions apps/sim/app/workspace/[workspaceId]/files/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1534,13 +1534,9 @@ export function Files() {

useEffect(() => {
if (isNewFile && fileIdFromRoute) {
router.replace(
currentFolderId
? `/workspace/${workspaceId}/files/${fileIdFromRoute}?folderId=${currentFolderId}`
: `/workspace/${workspaceId}/files/${fileIdFromRoute}`
)
void setFilesParams({ new: null }, { history: 'replace', scroll: false })
}
}, [isNewFile, fileIdFromRoute, router, workspaceId, currentFolderId])
}, [isNewFile, fileIdFromRoute, setFilesParams])

useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/files/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { getSession } from '@/lib/auth'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { Files } from '@/app/workspace/[workspaceId]/files/files'
import FilesLoading from '@/app/workspace/[workspaceId]/files/loading'
import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch'
import { Files } from './files'
import FilesLoading from './loading'

export const metadata: Metadata = {
title: 'Files',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { Document } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document'
import DocumentLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading'

interface DocumentPageProps {
params: Promise<{
Expand All @@ -24,7 +25,7 @@ export default async function DocumentChunksPage({ params, searchParams }: Docum
const [{ id, documentId }, { kbName, docName }] = await Promise.all([params, searchParams])

return (
<Suspense fallback={null}>
<Suspense fallback={<DocumentLoading />}>
<Document
knowledgeBaseId={id}
documentId={documentId}
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/loading'

interface PageProps {
params: Promise<{
Expand All @@ -20,7 +21,7 @@ export default async function KnowledgeBasePage({ params, searchParams }: PagePr
const [{ id }, { kbName }] = await Promise.all([params, searchParams])

return (
<Suspense fallback={null}>
<Suspense fallback={<KnowledgeBaseLoading />}>
<KnowledgeBase id={id} knowledgeBaseName={kbName || 'Knowledge Base'} />
</Suspense>
)
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { Suspense } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { Knowledge } from '@/app/workspace/[workspaceId]/knowledge/knowledge'
import KnowledgeLoading from '@/app/workspace/[workspaceId]/knowledge/loading'
import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch'
import { Knowledge } from './knowledge'

export const metadata: Metadata = {
title: 'Knowledge Base',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useCallback, useRef, useState } from 'react'
import type { ParsedFilter } from '@/lib/logs/query-parser'
import { parseQuery } from '@/lib/logs/query-parser'
import type {
Suggestion,
SuggestionGroup,
Expand All @@ -11,21 +10,16 @@ interface UseSearchStateOptions {
onFiltersChange: (filters: ParsedFilter[], textSearch: string) => void
getSuggestions: (input: string) => SuggestionGroup | null
debounceMs?: number
initialQuery?: string
}

export function useSearchState({
onFiltersChange,
getSuggestions,
debounceMs = 100,
initialQuery,
}: UseSearchStateOptions) {
const [initialParsed] = useState(() =>
initialQuery ? parseQuery(initialQuery) : { filters: [] as ParsedFilter[], textSearch: '' }
)
const [appliedFilters, setAppliedFilters] = useState<ParsedFilter[]>(initialParsed.filters)
const [appliedFilters, setAppliedFilters] = useState<ParsedFilter[]>([])
const [currentInput, setCurrentInput] = useState('')
const [textSearch, setTextSearch] = useState<string>(initialParsed.textSearch)
const [textSearch, setTextSearch] = useState('')

const [isOpen, setIsOpen] = useState(false)
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type { SortConfig, SortDirection, SortField, TerminalFilters } from '../types'
export type { SortDirection, TerminalFilters } from '../types'
export { useOutputPanelResize } from './use-output-panel-resize'
export { useTerminalFilters } from './use-terminal-filters'
export { useTerminalResize } from './use-terminal-resize'
Loading
Loading