From c71d78b84574b1cc54f471eb9c61edc91500d1e9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 23:06:56 -0700 Subject: [PATCH 1/2] improvement(url-state): use nuqs setters and derive state instead of mirroring it Wave 1 of a URL-state audit sweep. - files: replace the last hand-built same-path query mutation with the nuqs group setter, which no longer drops shareFileId/search/type/size/uploaded-by/sort/dir - suspense: give six page entries their co-located loading.tsx skeleton instead of fallback={null} - invite: derive isNewUser/urlError/token during render so the invitation query key is correct on first commit - resume: derive selectedStatus/queuePosition from the query cache the mutation already writes - verify, logs, terminal: delete dead and duplicate state - rules: document same-path router.replace as a query mutation, and the loading.tsx-as-Suspense-fallback convention --- .claude/rules/sim-url-state.md | 21 ++++++++-- apps/sim/app/(auth)/login/page.tsx | 3 +- apps/sim/app/(auth)/sso/page.tsx | 3 +- .../sim/app/(auth)/verify/use-verification.ts | 7 ---- apps/sim/app/(auth)/verify/verify-content.tsx | 16 +++----- .../[executionId]/resume-page-client.tsx | 16 ++------ apps/sim/app/invite/[id]/invite.tsx | 34 +++++++--------- apps/sim/app/invite/[id]/page.tsx | 3 +- .../[workspaceId]/files/[fileId]/page.tsx | 5 ++- .../workspace/[workspaceId]/files/files.tsx | 8 +--- .../workspace/[workspaceId]/files/page.tsx | 4 +- .../knowledge/[id]/[documentId]/page.tsx | 3 +- .../[workspaceId]/knowledge/[id]/page.tsx | 3 +- .../[workspaceId]/knowledge/page.tsx | 2 +- .../logs/hooks/use-search-state.ts | 10 +---- .../components/terminal/hooks/index.ts | 2 +- .../terminal/hooks/use-terminal-filters.ts | 39 ++++--------------- .../components/terminal/terminal.tsx | 8 ++-- .../[workflowId]/components/terminal/types.ts | 13 ------- 19 files changed, 75 insertions(+), 125 deletions(-) diff --git a/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md index a65acd67004..9312fe72c9b 100644 --- a/.claude/rules/sim-url-state.md +++ b/.claude/rules/sim-url-state.md @@ -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). @@ -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 @@ -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 `` 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 `` 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' + +}> + + +``` + +Reference: `apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx`. + +This applies to **page entries**. An inner `` 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 diff --git a/apps/sim/app/(auth)/login/page.tsx b/apps/sim/app/(auth)/login/page.tsx index 1490ac85e44..3b0c3f6a96a 100644 --- a/apps/sim/app/(auth)/login/page.tsx +++ b/apps/sim/app/(auth)/login/page.tsx @@ -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 = { @@ -15,7 +16,7 @@ export default async function LoginPage() { await getOAuthProviderStatus() return ( - + }> + }> ) diff --git a/apps/sim/app/(auth)/verify/use-verification.ts b/apps/sim/app/(auth)/verify/use-verification.ts index cd88a3d32c6..96bd55f1f88 100644 --- a/apps/sim/app/(auth)/verify/use-verification.ts +++ b/apps/sim/app/(auth)/verify/use-verification.ts @@ -81,7 +81,6 @@ export function useVerification({ const [email, setEmail] = useState('') const [status, setStatus] = useState('idle') const [isResending, setIsResending] = useState(false) - const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false) const [errorMessage, setErrorMessage] = useState('') useEffect(() => { @@ -89,12 +88,6 @@ export function useVerification({ if (storedEmail) setEmail(storedEmail) }, []) - useEffect(() => { - if (email && !isSendingInitialOtp && hasEmailService) { - setIsSendingInitialOtp(true) - } - }, [email, isSendingInitialOtp, hasEmailService]) - const isOtpComplete = otp.length === 6 async function verifyCode() { diff --git a/apps/sim/app/(auth)/verify/verify-content.tsx b/apps/sim/app/(auth)/verify/verify-content.tsx index 4fc9009a2f5..88d1c2e9d67 100644 --- a/apps/sim/app/(auth)/verify/verify-content.tsx +++ b/apps/sim/app/(auth)/verify/verify-content.tsx @@ -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) } @@ -128,7 +122,7 @@ function VerificationForm({ Resend in {countdown}s ) : ( - + Resend )} diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index a6d0d89cbf5..5576c035050 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -185,9 +185,9 @@ export default function ResumeExecutionPage({ executionId, selectedContextId ?? undefined ) - const [selectedStatus, setSelectedStatus] = - useState('paused') - const [queuePosition, setQueuePosition] = useState(undefined) + const selectedStatus: PausePointWithQueue['resumeStatus'] = + selectedDetail?.pausePoint.resumeStatus ?? 'paused' + const queuePosition = selectedDetail?.pausePoint.queuePosition const resumeInputsRef = useRef>({}) const [resumeInput, setResumeInput] = useState('') const [formValuesByContext, setFormValuesByContext] = useState< @@ -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( @@ -524,8 +521,6 @@ export default function ResumeExecutionPage({ useEffect(() => { if (!selectedDetail) return - setSelectedStatus(selectedDetail.pausePoint.resumeStatus) - setQueuePosition(selectedDetail.pausePoint.queuePosition) seedFormFromDetail(selectedDetail) }, [selectedDetail, seedFormFromDetail]) @@ -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' @@ -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.' diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index b6b2e15ece3..544277544c4 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -278,32 +278,28 @@ export default function Invite({ registrationDisabled }: InviteProps) { const { data: session, isPending } = useSession() const queryClient = useQueryClient() const [actionError, setActionError] = useState(null) - const [urlError, setUrlError] = useState(null) const [isAccepting, setIsAccepting] = useState(false) const [accepted, setAccepted] = useState(false) - const [isNewUser, setIsNewUser] = useState(false) - const [token, setToken] = useState(null) + const [storedToken, setStoredToken] = useState(null) - 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 - 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), diff --git a/apps/sim/app/invite/[id]/page.tsx b/apps/sim/app/invite/[id]/page.tsx index 957fdb4ba74..46e9faac0c4 100644 --- a/apps/sim/app/invite/[id]/page.tsx +++ b/apps/sim/app/invite/[id]/page.tsx @@ -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', @@ -12,7 +13,7 @@ export const dynamic = 'force-dynamic' export default function InvitePage() { return ( - + }> ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx index 2bc2bef02d6..590e94b0816 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx @@ -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', @@ -9,7 +10,7 @@ export const metadata: Metadata = { export default function FilesFilePage() { return ( - + }> ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index ad374824b16..bb716412d30 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -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) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/page.tsx index ba8d25ce7a4..389b4d17ded 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/page.tsx @@ -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', diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx index 9403f0a2448..0753a1df61d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx @@ -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<{ @@ -24,7 +25,7 @@ export default async function DocumentChunksPage({ params, searchParams }: Docum const [{ id, documentId }, { kbName, docName }] = await Promise.all([params, searchParams]) return ( - + }> + }> ) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx index 402d437b4f0..257477c9a7d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx @@ -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', diff --git a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-search-state.ts b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-search-state.ts index 19af1dd346f..64a8740b7a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-search-state.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-search-state.ts @@ -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, @@ -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(initialParsed.filters) + const [appliedFilters, setAppliedFilters] = useState([]) const [currentInput, setCurrentInput] = useState('') - const [textSearch, setTextSearch] = useState(initialParsed.textSearch) + const [textSearch, setTextSearch] = useState('') const [isOpen, setIsOpen] = useState(false) const [suggestions, setSuggestions] = useState([]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/index.ts index adf2b160724..311c3e11f44 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/index.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts index c712864cf33..a5c9a227a34 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-filters.ts @@ -1,6 +1,6 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useState } from 'react' import type { - SortConfig, + SortDirection, TerminalFilters, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' import type { ConsoleEntry } from '@/stores/terminal' @@ -17,14 +17,8 @@ export function useTerminalFilters() { statuses: new Set(), }) - const [sortConfig, setSortConfig] = useState({ - field: 'timestamp', - direction: 'desc', - }) + const [sortDirection, setSortDirection] = useState('desc') - /** - * Toggles a block filter by block ID - */ const toggleBlock = useCallback((blockId: string) => { setFilters((prev) => { const newBlockIds = new Set(prev.blockIds) @@ -37,9 +31,6 @@ export function useTerminalFilters() { }) }, []) - /** - * Toggles a status filter - */ const toggleStatus = useCallback((status: 'error' | 'info') => { setFilters((prev) => { const newStatuses = new Set(prev.statuses) @@ -52,19 +43,10 @@ export function useTerminalFilters() { }) }, []) - /** - * Toggles sort direction between ascending and descending - */ const toggleSort = useCallback(() => { - setSortConfig((prev) => ({ - field: prev.field, - direction: prev.direction === 'desc' ? 'asc' : 'desc', - })) + setSortDirection((prev) => (prev === 'desc' ? 'asc' : 'desc')) }, []) - /** - * Clears all filters - */ const clearFilters = useCallback(() => { setFilters({ blockIds: new Set(), @@ -72,12 +54,7 @@ export function useTerminalFilters() { }) }, []) - /** - * Checks if any filters are active - */ - const hasActiveFilters = useMemo(() => { - return filters.blockIds.size > 0 || filters.statuses.size > 0 - }, [filters]) + const hasActiveFilters = filters.blockIds.size > 0 || filters.statuses.size > 0 /** * Filters and sorts console entries based on current filter and sort state @@ -108,17 +85,17 @@ export function useTerminalFilters() { // Sort by executionOrder (monotonically increasing integer from server) result = [...result].sort((a, b) => { const comparison = a.executionOrder - b.executionOrder - return sortConfig.direction === 'asc' ? comparison : -comparison + return sortDirection === 'asc' ? comparison : -comparison }) return result }, - [filters, hasActiveFilters, sortConfig] + [filters, hasActiveFilters, sortDirection] ) return { filters, - sortConfig, + sortDirection, toggleBlock, toggleStatus, toggleSort, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index 75c3c183bfa..803e5024a4d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -715,7 +715,7 @@ export const Terminal = memo(function Terminal() { const { filters, - sortConfig, + sortDirection, toggleBlock, toggleStatus, toggleSort, @@ -1328,10 +1328,10 @@ export const Terminal = memo(function Terminal() { aria-label='Sort by timestamp' className='!p-1.5 -m-1.5' > - {sortConfig.direction === 'desc' ? ( - + {sortDirection === 'desc' ? ( + ) : ( - + )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts index 0c97956e2ce..80865a985e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts @@ -14,24 +14,11 @@ export interface ContextMenuPosition { y: number } -/** - * Sort field options for terminal entries - */ -export type SortField = 'timestamp' - /** * Sort direction options */ export type SortDirection = 'asc' | 'desc' -/** - * Sort configuration for terminal entries - */ -export interface SortConfig { - field: SortField - direction: SortDirection -} - /** * Status type for console entries */ From c99f560ccf078488fc5a4d52b3817f1be36127d8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 23:11:58 -0700 Subject: [PATCH 2/2] fix(invite): wait for the stored token before enabling the invitation query An authenticated user opening an invite without a token in the URL fired the query with a null token before the effect restored the session-stored one, producing a transient forbidden state and a redundant request under a second cache key. Distinguish 'storage not yet read' (undefined) from 'read and empty' (null) and gate the query on that. --- apps/sim/app/invite/[id]/invite.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index 544277544c4..d1ce0067047 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -280,7 +280,8 @@ export default function Invite({ registrationDisabled }: InviteProps) { const [actionError, setActionError] = useState(null) const [isAccepting, setIsAccepting] = useState(false) const [accepted, setAccepted] = useState(false) - const [storedToken, setStoredToken] = useState(null) + /** `undefined` until the effect reads storage; `null` once read and empty. */ + const [storedToken, setStoredToken] = useState(undefined) const isNewUser = searchParams.get('new') === 'true' const errorReason = searchParams.get('error') @@ -291,7 +292,8 @@ export default function Invite({ registrationDisabled }: InviteProps) { * commit; an effect-set token refetches under a second key whenever the * session cache is already warm at mount. */ - const token = tokenFromQuery ?? storedToken + const token = tokenFromQuery ?? storedToken ?? null + const isTokenResolved = tokenFromQuery !== null || storedToken !== undefined useEffect(() => { if (tokenFromQuery) { @@ -302,7 +304,7 @@ export default function Invite({ registrationDisabled }: InviteProps) { }, [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