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
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user