chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,41 @@
'use client'
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { hydrateBlockVisibility, resetBlockVisibilityForSwitch } from '@/blocks/visibility/client'
import { useBlockVisibility } from '@/hooks/queries/block-visibility'
/**
* Hydrates the client block-visibility overlay for the active workspace so the
* registry accessors project the viewer's revealed/disabled preview blocks.
* Mounted once in the workspace layout, next to `CustomBlocksLoader`.
*
* First paint needs no prefetch: `preview: true` blocks are fail-closed until
* this hydrate lands, so the fetch only ever reveals (benign pop-in) or applies
* a kill switch to an already-public block. Identical refetches are absorbed by
* the deep-equal guard inside `hydrateBlockVisibility`.
*/
export function BlockVisibilityLoader() {
const params = useParams()
const workspaceId = params?.workspaceId as string | undefined
const { data } = useBlockVisibility(workspaceId)
useEffect(() => {
// On a workspace switch the query key changes and `data` is undefined while
// the new projection loads — reset fail-closed so the previous workspace's
// preview reveals never linger across orgs, while carrying kill-switch
// entries over so disabled blocks don't flash back during the flight
// window. No-ops on first mount (nothing hydrated yet).
if (!data) {
resetBlockVisibilityForSwitch()
return
}
hydrateBlockVisibility({
revealed: new Set(data.revealed),
disabled: new Set(data.disabled),
previewTagged: new Set(data.previewTagged),
})
}, [data])
return null
}
@@ -0,0 +1,55 @@
'use client'
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { buildCustomBlockConfig } from '@/blocks/custom/build-config'
import { hydrateClientCustomBlocks } from '@/blocks/custom/client-overlay'
import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon'
import { useWhitelabelSettings } from '@/ee/whitelabeling/hooks/whitelabel'
import { useCustomBlocks } from '@/hooks/queries/custom-blocks'
/**
* Hydrates the client custom-block registry overlay from the active workspace's
* org custom blocks. Mounted once in the workspace layout so every surface that
* resolves blocks synchronously — the canvas, the block palette, copilot mentions,
* and the Access Control "Blocks" list — sees custom blocks. Re-hydrates on
* workspace switch (the query key changes) and on any publish/edit/unpublish.
*/
export function CustomBlocksLoader() {
const params = useParams()
const workspaceId = params?.workspaceId as string | undefined
const { data } = useCustomBlocks(workspaceId)
// Blocks with no uploaded icon fall back to the org's whitelabel logo, then the
// default glyph. All blocks share the org, so read it off the first row.
const { data: whitelabel } = useWhitelabelSettings(data?.[0]?.organizationId)
const fallbackIconUrl = whitelabel?.logoUrl ?? null
useEffect(() => {
hydrateClientCustomBlocks(
// Disabled blocks stay resolvable (so a still-placed instance renders on the
// canvas and survives serialization instead of vanishing) but are hidden from
// the palette so no new instance can be placed; a run fails loudly server-side.
(data ?? []).map((block) => {
const effectiveIcon = block.iconUrl || fallbackIconUrl
return buildCustomBlockConfig(
{
type: block.type,
name: block.name,
description: block.description,
workflowId: block.workflowId,
exposedOutputs: block.exposedOutputs,
},
block.inputFields,
{
icon: getCustomBlockIcon(block.iconUrl, fallbackIconUrl),
bgColor: effectiveIcon ? 'transparent' : undefined,
hideFromToolbar: !block.enabled,
}
)
})
)
}, [data, fallbackIconUrl])
return null
}
@@ -0,0 +1,161 @@
/**
* @vitest-environment jsdom
*/
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockIsMac } = vi.hoisted(() => ({ mockIsMac: vi.fn(() => false) }))
vi.mock('@/lib/core/utils/platform', () => ({ isMacPlatform: mockIsMac }))
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
import {
GlobalCommandsProvider,
useRegisterGlobalCommands,
} from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
function RegisterModK({ handler }: { handler: () => void }) {
useRegisterGlobalCommands([{ id: 'search', shortcut: 'Mod+K', handler }])
return null
}
function RegisterModKOutsideEditable({ handler }: { handler: () => void }) {
useRegisterGlobalCommands([{ id: 'search', shortcut: 'Mod+K', handler, allowInEditable: false }])
return null
}
let container: HTMLDivElement
let root: Root
function mount(ui: ReactNode) {
act(() => {
root.render(ui)
})
}
/** Non-mac (mocked): `Mod` resolves to Ctrl, so Ctrl+K matches a `Mod+K` shortcut. */
function pressModK() {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true })
)
}
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
vi.clearAllMocks()
})
describe('GlobalCommandsProvider owned-shortcut yielding', () => {
it('fires a global command when nothing owns the shortcut', () => {
const handler = vi.fn()
mount(
<GlobalCommandsProvider>
<RegisterModK handler={handler} />
</GlobalCommandsProvider>
)
pressModK()
expect(handler).toHaveBeenCalledTimes(1)
})
it('yields the shortcut to a focused element that declares it owns it', () => {
const handler = vi.fn()
mount(
<GlobalCommandsProvider>
<RegisterModK handler={handler} />
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focusable stand-in for the editor */}
<div data-owned-shortcuts='Mod+K' tabIndex={0} />
</GlobalCommandsProvider>
)
;(container.querySelector('[data-owned-shortcuts]') as HTMLElement).focus()
pressModK()
expect(handler).not.toHaveBeenCalled()
})
it('still fires when the focused element owns only a different shortcut', () => {
const handler = vi.fn()
mount(
<GlobalCommandsProvider>
<RegisterModK handler={handler} />
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focusable stand-in for the editor */}
<div data-owned-shortcuts='Mod+B' tabIndex={0} />
</GlobalCommandsProvider>
)
;(container.querySelector('[data-owned-shortcuts]') as HTMLElement).focus()
pressModK()
expect(handler).toHaveBeenCalledTimes(1)
})
})
describe('GlobalCommandsProvider editable guard', () => {
/**
* jsdom does not implement `isContentEditable`, so stub the browser's computed
* editability on the element the test focuses.
*/
function focusWithEditability(element: HTMLElement, isContentEditable: boolean) {
Object.defineProperty(element, 'isContentEditable', { value: isContentEditable })
element.focus()
}
it('skips a non-editable command when focus is in an input', () => {
const handler = vi.fn()
mount(
<GlobalCommandsProvider>
<RegisterModKOutsideEditable handler={handler} />
<input type='text' />
</GlobalCommandsProvider>
)
;(container.querySelector('input') as HTMLInputElement).focus()
pressModK()
expect(handler).not.toHaveBeenCalled()
})
it('skips a non-editable command when focus is in a contenteditable element', () => {
const handler = vi.fn()
mount(
<GlobalCommandsProvider>
<RegisterModKOutsideEditable handler={handler} />
<div contentEditable />
</GlobalCommandsProvider>
)
focusWithEditability(container.querySelector('[contenteditable]') as HTMLElement, true)
pressModK()
expect(handler).not.toHaveBeenCalled()
})
it('skips a non-editable command when focus is on a descendant of a contenteditable root', () => {
const handler = vi.fn()
mount(
<GlobalCommandsProvider>
<RegisterModKOutsideEditable handler={handler} />
<div contentEditable>
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focusable stand-in for a node view inside the editor */}
<span tabIndex={0} />
</div>
</GlobalCommandsProvider>
)
focusWithEditability(container.querySelector('span') as HTMLElement, true)
pressModK()
expect(handler).not.toHaveBeenCalled()
})
it('fires a non-editable command when focus is in a contenteditable="false" element', () => {
const handler = vi.fn()
mount(
<GlobalCommandsProvider>
<RegisterModKOutsideEditable handler={handler} />
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focusable stand-in for a read-only editor */}
<div contentEditable={false} tabIndex={0} />
</GlobalCommandsProvider>
)
focusWithEditability(container.querySelector('[contenteditable]') as HTMLElement, false)
pressModK()
expect(handler).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,232 @@
'use client'
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from 'react'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { useRouter } from 'next/navigation'
import { isMacPlatform } from '@/lib/core/utils/platform'
const logger = createLogger('GlobalCommands')
export interface ParsedShortcut {
key: string
mod?: boolean
ctrl?: boolean
meta?: boolean
shift?: boolean
alt?: boolean
}
export interface GlobalCommand {
id?: string
shortcut: string
allowInEditable?: boolean
handler: (event: KeyboardEvent) => void
}
interface RegistryCommand extends GlobalCommand {
id: string
parsed: ParsedShortcut
}
interface GlobalCommandsContextValue {
register: (commands: GlobalCommand[]) => () => void
invoke: (id: string) => boolean
}
const GlobalCommandsContext = createContext<GlobalCommandsContextValue | null>(null)
function parseShortcut(shortcut: string): ParsedShortcut {
const parts = shortcut.split('+').map((p) => p.trim())
const modifiers = new Set(parts.slice(0, -1).map((p) => p.toLowerCase()))
const last = parts[parts.length - 1]
return {
key: last.length === 1 ? last.toLowerCase() : last,
mod: modifiers.has('mod'),
ctrl: modifiers.has('ctrl'),
meta: modifiers.has('meta') || modifiers.has('cmd') || modifiers.has('command'),
shift: modifiers.has('shift'),
alt: modifiers.has('alt') || modifiers.has('option'),
}
}
/**
* Maps a KeyboardEvent.code value to the logical key name used in shortcut definitions.
* Needed for international keyboard layouts where e.key may produce unexpected characters
* (e.g. macOS Option+letter yields 'å' instead of 'a', dead keys yield 'Dead').
*/
function codeToKey(code: string): string | undefined {
if (code.startsWith('Key')) return code.slice(3).toLowerCase()
if (code.startsWith('Digit')) return code.slice(5)
return undefined
}
function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
const isMac = isMacPlatform()
const expectedCtrl = parsed.ctrl || (parsed.mod ? !isMac : false)
const expectedMeta = parsed.meta || (parsed.mod ? isMac : false)
const eventKey = e.key.length === 1 ? e.key.toLowerCase() : e.key
const keyMatches = eventKey === parsed.key || codeToKey(e.code) === parsed.key
return (
keyMatches &&
!!e.ctrlKey === !!expectedCtrl &&
!!e.metaKey === !!expectedMeta &&
!!e.shiftKey === !!parsed.shift &&
!!e.altKey === !!parsed.alt
)
}
/** Platform-resolved signature of a shortcut, so `Mod+K`, `Cmd+K`, and `Meta+K` compare equal on mac. */
function shortcutSignature(parsed: ParsedShortcut, isMac: boolean): string {
const ctrl = parsed.ctrl || (parsed.mod ? !isMac : false)
const meta = parsed.meta || (parsed.mod ? isMac : false)
return `${parsed.key}|${+ctrl}|${+meta}|${+!!parsed.shift}|${+!!parsed.alt}`
}
/**
* Whether `element` is an editable region for the purposes of the editable guard.
* `isContentEditable` is the browser's computed editability, so focusable descendants of a
* `contenteditable` root count as editable, while `contenteditable="false"` (e.g. a rich-text
* editor in read-only mode) does not — commands with `allowInEditable: false` still fire there.
*/
function isEditableElement(element: Element | null): boolean {
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) return true
return element instanceof HTMLElement && element.isContentEditable
}
/**
* Whether the focused element (or an ancestor) declares it owns `parsed` via a comma-separated
* `data-owned-shortcuts` attribute (e.g. a rich-text editor that binds `Mod+K` to links). Such a
* shortcut is left for that element to handle instead of firing the global command.
*/
function focusedElementOwnsShortcut(parsed: ParsedShortcut, isMac: boolean): boolean {
const active = document.activeElement
const owner = active instanceof HTMLElement ? active.closest('[data-owned-shortcuts]') : null
if (!owner) return false
const target = shortcutSignature(parsed, isMac)
return (owner.getAttribute('data-owned-shortcuts') ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
.some((entry) => shortcutSignature(parseShortcut(entry), isMac) === target)
}
export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
const registryRef = useRef<Map<string, RegistryCommand>>(new Map())
const isMac = useMemo(() => isMacPlatform(), [])
const router = useRouter()
const register = useCallback((commands: GlobalCommand[]) => {
const createdIds: string[] = []
for (const cmd of commands) {
const id = cmd.id ?? generateId()
const parsed = parseShortcut(cmd.shortcut)
registryRef.current.set(id, {
...cmd,
id,
parsed,
allowInEditable: cmd.allowInEditable ?? true,
})
createdIds.push(id)
}
return () => {
for (const id of createdIds) {
registryRef.current.delete(id)
}
}
}, [])
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.isComposing) return
for (const [, cmd] of registryRef.current) {
if (!cmd.allowInEditable && isEditableElement(document.activeElement)) continue
if (matchesShortcut(e, cmd.parsed)) {
if (focusedElementOwnsShortcut(cmd.parsed, isMac)) continue
e.preventDefault()
e.stopPropagation()
try {
cmd.handler(e)
} catch (err) {
logger.error('Global command handler threw', { id: cmd.id, err })
}
return
}
}
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [isMac, router])
const invoke = useCallback((id: string): boolean => {
const cmd = registryRef.current.get(id)
if (!cmd) return false
try {
cmd.handler(new KeyboardEvent('keydown'))
} catch (err) {
logger.error('Global command handler threw', { id, err })
}
return true
}, [])
const value = useMemo<GlobalCommandsContextValue>(
() => ({ register, invoke }),
[register, invoke]
)
return <GlobalCommandsContext.Provider value={value}>{children}</GlobalCommandsContext.Provider>
}
/**
* Returns a function that runs a registered global command by id, mirroring its
* keyboard shortcut exactly. Returns `false` when no command with that id is
* currently registered (e.g. a workflow-only command invoked off-canvas), so
* callers can offer the action safely without knowing what is mounted.
*/
export function useInvokeGlobalCommand(): (id: string) => boolean {
const ctx = useContext(GlobalCommandsContext)
if (!ctx) {
throw new Error('useInvokeGlobalCommand must be used within GlobalCommandsProvider')
}
return ctx.invoke
}
export function useRegisterGlobalCommands(commands: GlobalCommand[] | (() => GlobalCommand[])) {
const ctx = useContext(GlobalCommandsContext)
if (!ctx) {
throw new Error('useRegisterGlobalCommands must be used within GlobalCommandsProvider')
}
const commandsRef = useRef<GlobalCommand[]>([])
const list = typeof commands === 'function' ? commands() : commands
commandsRef.current = list
useEffect(() => {
const wrappedCommands = commandsRef.current.map((cmd) => ({
...cmd,
handler: (event: KeyboardEvent) => {
const currentCmd = commandsRef.current.find((c) => c.id === cmd.id)
if (currentCmd) {
currentCmd.handler(event)
}
},
}))
const unregister = ctx.register(wrappedCommands)
return unregister
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}
@@ -0,0 +1,83 @@
'use client'
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { useProviderModels } from '@/hooks/queries/providers'
import {
updateBasetenProviderModels,
updateFireworksProviderModels,
updateLiteLLMProviderModels,
updateOllamaCloudProviderModels,
updateOllamaProviderModels,
updateOpenRouterProviderModels,
updateTogetherProviderModels,
updateVLLMProviderModels,
} from '@/providers/utils'
import { type ProviderName, useProvidersStore } from '@/stores/providers'
const logger = createLogger('ProviderModelsLoader')
function useSyncProvider(provider: ProviderName, workspaceId?: string) {
const setProviderModels = useProvidersStore((state) => state.setProviderModels)
const setProviderLoading = useProvidersStore((state) => state.setProviderLoading)
const setOpenRouterModelInfo = useProvidersStore((state) => state.setOpenRouterModelInfo)
const { data, isLoading, isFetching, error } = useProviderModels(provider, workspaceId)
useEffect(() => {
setProviderLoading(provider, isLoading || isFetching)
}, [provider, isLoading, isFetching, setProviderLoading])
useEffect(() => {
if (!data) return
try {
if (provider === 'ollama') {
updateOllamaProviderModels(data.models)
} else if (provider === 'ollama-cloud') {
void updateOllamaCloudProviderModels(data.models)
} else if (provider === 'vllm') {
updateVLLMProviderModels(data.models)
} else if (provider === 'litellm') {
updateLiteLLMProviderModels(data.models)
} else if (provider === 'openrouter') {
void updateOpenRouterProviderModels(data.models)
if (data.modelInfo) {
setOpenRouterModelInfo(data.modelInfo)
}
} else if (provider === 'fireworks') {
void updateFireworksProviderModels(data.models)
} else if (provider === 'together') {
void updateTogetherProviderModels(data.models)
} else if (provider === 'baseten') {
void updateBasetenProviderModels(data.models)
}
} catch (syncError) {
logger.warn(`Failed to sync provider definitions for ${provider}`, syncError as Error)
}
setProviderModels(provider, data.models)
}, [provider, data, setProviderModels, setOpenRouterModelInfo])
useEffect(() => {
if (error) {
logger.error(`Failed to load ${provider} models`, error)
}
}, [provider, error])
}
export function ProviderModelsLoader() {
const params = useParams()
const workspaceId = params?.workspaceId as string | undefined
useSyncProvider('base')
useSyncProvider('ollama')
useSyncProvider('ollama-cloud', workspaceId)
useSyncProvider('vllm')
useSyncProvider('litellm')
useSyncProvider('openrouter')
useSyncProvider('fireworks', workspaceId)
useSyncProvider('together', workspaceId)
useSyncProvider('baseten', workspaceId)
return null
}
@@ -0,0 +1,12 @@
'use client'
import { useGeneralSettings } from '@/hooks/queries/general-settings'
/**
* Eagerly warms the settings cache on workspace entry.
* React Query handles fetching, caching, and deduplication automatically.
*/
export function SettingsLoader() {
useGeneralSettings()
return null
}
@@ -0,0 +1,247 @@
'use client'
import type React from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'
import { useToast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import {
useWorkspacePermissionsQuery,
type WorkspacePermissions,
workspaceKeys,
} from '@/hooks/queries/workspace'
import { useStableFlag } from '@/hooks/use-stable-flag'
import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
const logger = createLogger('WorkspacePermissionsProvider')
/**
* Anti-flicker timing for the "Reconnecting..." toast. Socket.IO flips
* `isReconnecting` on any disconnect — including sub-second transport hiccups
* that recover on the first retry — so we delay surfacing the toast until the
* drop has lasted long enough to matter, then hold it on screen long enough to
* read. Together these suppress both flicker modes (flash-on and flash-off)
* while still alerting on real outages.
*/
const RECONNECTING_TOAST_DELAY_MS = 2000
const RECONNECTING_TOAST_MIN_VISIBLE_MS = 1500
interface PersistentToastOptions {
description?: string
action?: { label: string; onClick: () => void }
}
/**
* Shows a persistent error toast while `message` is non-null, replaces it when
* the message changes, and dismisses it when the message becomes null or the
* owning component unmounts.
*/
function usePersistentErrorToast(message: string | null, options?: PersistentToastOptions) {
const { toast } = useToast()
const toastIdRef = useRef<string | null>(null)
const shownMessageRef = useRef<string | null>(null)
const optionsRef = useRef(options)
optionsRef.current = options
const dismiss = useCallback(() => {
if (!toastIdRef.current) {
return
}
toast.dismiss(toastIdRef.current)
toastIdRef.current = null
shownMessageRef.current = null
}, [])
useEffect(() => {
if (!message) {
dismiss()
return
}
if (toastIdRef.current && shownMessageRef.current === message) {
return
}
dismiss()
try {
toastIdRef.current = toast.error(message, {
...optionsRef.current,
duration: 0,
persistAcrossRoutes: true,
})
shownMessageRef.current = message
} catch (error) {
logger.error('Failed to show persistent notification', { error, message })
}
}, [dismiss, message])
useEffect(() => dismiss, [dismiss])
}
interface WorkspacePermissionsContextType {
workspacePermissions: WorkspacePermissions | null
permissionsLoading: boolean
permissionsError: string | null
updatePermissions: (newPermissions: WorkspacePermissions) => void
refetchPermissions: () => Promise<void>
userPermissions: WorkspaceUserPermissions & { isOfflineMode?: boolean }
}
const WorkspacePermissionsContext = createContext<WorkspacePermissionsContextType>({
workspacePermissions: null,
permissionsLoading: false,
permissionsError: null,
updatePermissions: () => {},
refetchPermissions: async () => {},
userPermissions: {
canRead: false,
canEdit: false,
canAdmin: false,
userPermissions: 'read',
isLoading: false,
error: null,
},
})
interface WorkspacePermissionsProviderProps {
children: React.ReactNode
}
/**
* Provides workspace permissions and connection-aware user access throughout the app.
* Enforces read-only mode when offline to prevent data loss.
*/
export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsProviderProps) {
const params = useParams()
const workspaceId = params?.workspaceId as string
const urlWorkflowId = params?.workflowId as string | undefined
const queryClient = useQueryClient()
const hasOperationError = useOperationQueueStore((state) => state.hasOperationError)
const { isReconnecting, isRetryingWorkflowJoin, blockedJoinWorkflowId } = useSocket()
const isOfflineMode = hasOperationError
const isJoinBlocked = Boolean(blockedJoinWorkflowId) && blockedJoinWorkflowId === urlWorkflowId
const showReconnecting = useStableFlag(isReconnecting, {
delayMs: RECONNECTING_TOAST_DELAY_MS,
minVisibleMs: RECONNECTING_TOAST_MIN_VISIBLE_MS,
})
const realtimeStatusMessage = isOfflineMode
? null
: showReconnecting
? 'Reconnecting...'
: isRetryingWorkflowJoin
? 'Joining workflow...'
: null
usePersistentErrorToast(realtimeStatusMessage)
// Offline mode only recovers via workspace switch or refresh; the join block
// lifts when the user targets a different workflow or refreshes.
usePersistentErrorToast(isOfflineMode ? 'Connection unavailable' : null, {
description: 'Recent changes may not have been saved. Refresh to resync.',
action: { label: 'Refresh', onClick: () => window.location.reload() },
})
usePersistentErrorToast(isJoinBlocked ? 'Unable to connect to workflow' : null, {
description: 'Changes cannot be saved. Refresh to retry.',
action: { label: 'Refresh', onClick: () => window.location.reload() },
})
const {
data: workspacePermissions,
isLoading: permissionsLoading,
error: permissionsErrorObj,
refetch,
} = useWorkspacePermissionsQuery(workspaceId)
const permissionsError = permissionsErrorObj?.message ?? null
const updatePermissions = useCallback(
(newPermissions: WorkspacePermissions) => {
if (!workspaceId) return
queryClient.setQueryData(workspaceKeys.permissions(workspaceId), newPermissions)
},
[workspaceId, queryClient]
)
const refetchPermissions = useCallback(async () => {
await refetch()
}, [refetch])
const baseUserPermissions = useUserPermissions(
workspacePermissions ?? null,
permissionsLoading,
permissionsError
)
const userPermissions = useMemo((): WorkspaceUserPermissions & { isOfflineMode?: boolean } => {
if (isOfflineMode || isJoinBlocked) {
return {
...baseUserPermissions,
canEdit: false,
canAdmin: false,
canRead: baseUserPermissions.canRead,
isOfflineMode,
}
}
return {
...baseUserPermissions,
isOfflineMode: false,
}
}, [baseUserPermissions, isOfflineMode, isJoinBlocked])
const contextValue = useMemo(
() => ({
workspacePermissions: workspacePermissions ?? null,
permissionsLoading,
permissionsError,
updatePermissions,
refetchPermissions,
userPermissions,
}),
[
workspacePermissions,
permissionsLoading,
permissionsError,
updatePermissions,
refetchPermissions,
userPermissions,
]
)
return (
<WorkspacePermissionsContext.Provider value={contextValue}>
{children}
</WorkspacePermissionsContext.Provider>
)
}
/**
* Accesses workspace permissions data and operations from context.
* Must be used within a WorkspacePermissionsProvider.
*/
export function useWorkspacePermissionsContext(): WorkspacePermissionsContextType {
const context = useContext(WorkspacePermissionsContext)
if (!context) {
throw new Error(
'useWorkspacePermissionsContext must be used within a WorkspacePermissionsProvider'
)
}
return context
}
/**
* Accesses the current user's computed permissions including offline mode status.
* Convenience hook that extracts userPermissions from the context.
*/
export function useUserPermissionsContext(): WorkspaceUserPermissions & {
isOfflineMode?: boolean
} {
const { userPermissions } = useWorkspacePermissionsContext()
return userPermissions
}
@@ -0,0 +1,46 @@
'use client'
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
/**
* Keeps workflow registry workspace scope synchronized with the current route.
*/
export function WorkspaceScopeSync() {
const { workspaceId } = useParams<{ workspaceId: string }>()
const hydrationWorkspaceId = useWorkflowRegistry((state) => state.hydration.workspaceId)
const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace)
const posthog = usePostHog()
const { data: workspaceData } = useWorkspacesWithMetadata()
const activeWorkspace = workspaceData?.workspaces.find((ws) => ws.id === workspaceId)
const workspaceName = activeWorkspace?.name
const organizationId = activeWorkspace?.organizationId ?? null
useEffect(() => {
// Wait for metadata so the workspace and org groups switch together; acting
// mid-load (organizationId transiently null) would mismatch or strip them.
if (!workspaceId || !activeWorkspace) return
if (organizationId) {
posthog?.group('organization', organizationId)
} else {
// No org — clear any stale org group; resetGroups clears all, so the
// workspace group is re-applied immediately below.
posthog?.resetGroups()
}
posthog?.group('workspace', workspaceId, workspaceName ? { name: workspaceName } : undefined)
}, [posthog, workspaceId, workspaceName, organizationId, activeWorkspace])
useEffect(() => {
if (!workspaceId || hydrationWorkspaceId === workspaceId) {
return
}
switchToWorkspace(workspaceId)
}, [hydrationWorkspaceId, switchToWorkspace, workspaceId])
return null
}