Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

306 lines
10 KiB
TypeScript

'use client'
import {
type ComponentType,
createContext,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Chip, ChipInput, ChipLink, cn, Search, Tooltip } from '@sim/emcn'
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
/**
* A collapsed desktop sidebar removes the content shell's 8px top gutter and the
* pane's 1px border. Top-level settings headers have no left-side control to keep
* clear of the traffic lights, so they reserve only those missing 9px instead of
* the full title-bar lane. Their actions and body therefore stay at the same
* window-relative height as the expanded layout. Detail headers with a Back
* control retain the full lane inset.
*/
const COLLAPSED_DESKTOP_TOP_LEVEL_INSET =
'[[data-sim-desktop-title-bar=inset]_[data-sidebar-collapsed]_&]:[--workspace-content-title-bar-inset:9px]'
export interface SettingsAction {
/** Stable render identity. Falls back to `text`, which remounts the chip whenever the label flips (Save → Saving...). */
id?: string
text: string
textTone?: 'error'
icon?: ComponentType<{ className?: string }>
variant?: 'primary' | 'destructive'
active?: boolean
onSelect: () => void
disabled?: boolean
tooltip?: string
onPrefetch?: () => void
}
export interface SettingsHeaderSearch {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
}
export interface SettingsBackAction {
text: string
icon?: ComponentType<{ className?: string }>
onSelect: () => void
}
export interface SettingsHeaderConfig {
title?: string
description?: string
docsLink?: string
back?: SettingsBackAction
actions?: SettingsAction[]
search?: SettingsHeaderSearch
scrollContainerRef?: Ref<HTMLDivElement>
}
const EMPTY_CONFIG: SettingsHeaderConfig = {}
const RegisterContext = createContext<((config: SettingsHeaderConfig) => void) | null>(null)
interface ReadContextValue {
configRef: { current: SettingsHeaderConfig }
signature: string
}
const ReadContext = createContext<ReadContextValue | null>(null)
function computeSignature(config: SettingsHeaderConfig): string {
return JSON.stringify({
title: config.title ?? '',
description: config.description ?? '',
docsLink: config.docsLink ?? '',
back: config.back ? [config.back.text, config.back.icon ? 1 : 0] : null,
actions: config.actions?.map((action) => [
action.text,
// `id` participates in ordering, so a config that changes only the id
// must still re-render — the sort key cannot be wider than the signature.
action.id ?? '',
action.textTone ?? '',
action.variant ?? '',
action.active ?? false,
action.disabled ?? false,
action.icon ? 1 : 0,
action.tooltip ?? '',
action.onPrefetch ? 1 : 0,
]),
search: config.search
? [config.search.value, config.search.placeholder ?? '', config.search.disabled ?? false]
: null,
})
}
export function SettingsHeaderProvider({ children }: { children: ReactNode }) {
const configRef = useRef<SettingsHeaderConfig>(EMPTY_CONFIG)
const [signature, setSignature] = useState('')
const register = useCallback((config: SettingsHeaderConfig) => {
configRef.current = config
const next = computeSignature(config)
setSignature((previous) => (previous === next ? previous : next))
}, [])
const readValue = useMemo<ReadContextValue>(() => ({ configRef, signature }), [signature])
return (
<RegisterContext.Provider value={register}>
<ReadContext.Provider value={readValue}>{children}</ReadContext.Provider>
</RegisterContext.Provider>
)
}
export function useSettingsHeader(config: SettingsHeaderConfig) {
const register = useContext(RegisterContext)
useIsomorphicLayoutEffect(() => {
register?.(config)
})
useIsomorphicLayoutEffect(() => {
return () => register?.(EMPTY_CONFIG)
}, [register])
}
interface SettingsActionChipProps {
/** Presentation fields plus the default `onSelect` / `onPrefetch` handlers. */
action: SettingsAction
/** Overrides `action.onSelect` — the header shell passes a ref-reading indirection to avoid stale closures. */
onSelect?: () => void
/** Overrides `action.onPrefetch`, same reason. */
onPrefetch?: () => void
}
/**
* The one chip rendering of a {@link SettingsAction}. Both header stacks render
* through this, so an action's chrome — tone, icon, variant, disabled, tooltip —
* is identical wherever it is mounted. Container spacing still belongs to the
* enclosing shell.
*/
export function SettingsActionChip({
action,
onSelect = action.onSelect,
onPrefetch = action.onPrefetch,
}: SettingsActionChipProps) {
const chip = (
<Chip
variant={action.variant}
active={action.active}
leftIcon={action.icon}
onClick={onSelect}
onMouseEnter={onPrefetch}
onFocus={onPrefetch}
disabled={action.disabled}
// A disabled <button> is not a hit-test target, so the tooltip's wrapping
// span would never see pointerenter and the explanation would never show.
className={cn(action.tooltip && action.disabled && 'pointer-events-none')}
>
{action.textTone === 'error' ? (
<span className='text-[var(--text-error)]'>{action.text}</span>
) : (
action.text
)}
</Chip>
)
if (!action.tooltip) return chip
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='inline-flex'>{chip}</span>
</Tooltip.Trigger>
<Tooltip.Content>{action.tooltip}</Tooltip.Content>
</Tooltip.Root>
)
}
/**
* Renders a {@link SettingsAction} list as chips using each action's own
* handlers. This is the data path for headers that take a `ReactNode`
* (`CredentialDetailLayout`) — reach for it instead of hand-rolling `Chip`s, so
* those surfaces keep the same chrome as the settings shell. The shell itself
* maps through {@link SettingsActionChip} directly, since it must route every
* handler through a ref to avoid stale closures.
*/
export function SettingsActionChips({ actions }: { actions: SettingsAction[] }) {
return (
<>
{orderHeaderActions(actions).map(({ action }) => (
<SettingsActionChip key={action.id ?? action.text} action={action} />
))}
</>
)
}
/**
* Every header reads left→right as
* `[secondary actions] → [Delete] → [Discard] → [Save]`.
*
* Delete is placed by its `id`, not by where the caller happened to put it, so a
* page with no primary action still can't leave a destructive chip in the slot a
* primary would occupy.
*
* The shell enforces it rather than trusting callsites, because the natural way
* to write the array — spreading {@link saveDiscardActions} first, then adding a
* Delete — produces the opposite order and puts a destructive chip to the right
* of the primary one. Ranking is stable, so an action's position within its own
* band is still the caller's to choose.
*
* Pairs each action with its ORIGINAL index: the shell dereferences
* `actions[index]` on a live ref to dodge stale closures, so a reordered render
* must not renumber them.
*/
export function orderHeaderActions(
actions: SettingsAction[] | undefined
): { action: SettingsAction; index: number }[] {
const rank = (action: SettingsAction) => {
if (action.variant === 'primary') return 3
if (action.id === 'discard') return 2
if (action.id === 'delete') return 1
return 0
}
return (actions ?? [])
.map((action, index) => ({ action, index }))
.sort((a, b) => rank(a.action) - rank(b.action))
}
export function SettingsHeaderShell({ children }: { children: ReactNode }) {
const read = useContext(ReadContext)
const configRef = read?.configRef
const config = configRef?.current ?? EMPTY_CONFIG
const { title, description, docsLink, back, actions, search, scrollContainerRef } = config
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div
className={cn(
PAGE_HEADER_BAR,
'justify-between',
!back && COLLAPSED_DESKTOP_TOP_LEVEL_INSET
)}
>
{back ? (
<Chip leftIcon={back.icon} onClick={() => configRef?.current.back?.onSelect()}>
{back.text}
</Chip>
) : (
<div />
)}
<div className={HEADER_ACTION_CLUSTER}>
{docsLink && (
<ChipLink href={docsLink} target='_blank' rel='noopener noreferrer'>
Docs
</ChipLink>
)}
{orderHeaderActions(actions).map(({ action, index }) => (
<SettingsActionChip
key={action.id ?? action.text}
action={action}
onSelect={() => configRef?.current.actions?.[index]?.onSelect()}
onPrefetch={
action.onPrefetch
? () => configRef?.current.actions?.[index]?.onPrefetch?.()
: undefined
}
/>
))}
</div>
</div>
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'
>
<div className='mx-auto flex w-full max-w-[48rem] flex-col gap-7 pb-6'>
{(title || description) && (
<div className='flex flex-col gap-1'>
{title && <h1 className='text-[var(--text-body)] text-lg'>{title}</h1>}
{description && <p className='text-[var(--text-muted)] text-md'>{description}</p>}
</div>
)}
{search && (
<ChipInput
icon={Search}
placeholder={search.placeholder ?? 'Search...'}
value={search.value}
onChange={(event) => configRef?.current.search?.onChange(event.target.value)}
disabled={search.disabled}
autoComplete='off'
className='w-full'
/>
)}
{children}
</div>
</div>
</div>
)
}