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:
+455
@@ -0,0 +1,455 @@
|
||||
'use client'
|
||||
|
||||
import { type ComponentType, type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
InfoCard,
|
||||
InfoCardItem,
|
||||
InfoCardList,
|
||||
} from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import type { OAuthReturnContext } from '@/lib/credentials/client-state'
|
||||
import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state'
|
||||
import {
|
||||
getProviderIdFromServiceId,
|
||||
OAUTH_PROVIDERS,
|
||||
type OAuthProvider,
|
||||
parseProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { getScopeDescription } from '@/lib/oauth/utils'
|
||||
import { useCreateCredentialDraft, useWorkspaceCredentials } from '@/hooks/queries/credentials'
|
||||
import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections'
|
||||
|
||||
const logger = createLogger('ConnectOAuthModal')
|
||||
|
||||
/** Server-enforced max for `WorkspaceCredential.displayName` — see `lib/api/contracts/credentials.ts`. */
|
||||
const DISPLAY_NAME_MAX_LENGTH = 255
|
||||
|
||||
/**
|
||||
* Reserved tail budget when truncating the username so the auto-numbering
|
||||
* disambiguator (e.g. `" 9999"`) always fits within {@link DISPLAY_NAME_MAX_LENGTH}.
|
||||
*/
|
||||
const COLLISION_SUFFIX_RESERVATION = 5
|
||||
|
||||
/** Upper bound for the auto-numbering search — pathological if ever reached. */
|
||||
const MAX_COLLISION_INDEX = 10000
|
||||
|
||||
const EMPTY_SCOPES: readonly string[] = []
|
||||
|
||||
type ServiceIcon = ComponentType<{ className?: string }>
|
||||
|
||||
/** Scopes hidden from the permissions list — always present on Google flows. */
|
||||
function isHiddenScope(scope: string): boolean {
|
||||
return scope.includes('userinfo.email') || scope.includes('userinfo.profile')
|
||||
}
|
||||
|
||||
/**
|
||||
* Default credential display name. Produces `"{Name}'s {Service}"` when the
|
||||
* user's name is known, falling back to `"My {Service}"` otherwise. The
|
||||
* username is truncated so the full string (including any auto-numbering
|
||||
* disambiguator) stays within {@link DISPLAY_NAME_MAX_LENGTH}.
|
||||
*
|
||||
* When the base name collides with an existing credential in `takenNames`,
|
||||
* `" 2"`, `" 3"`, ... are appended until an unused name is found. Comparison
|
||||
* is case-insensitive to match the duplicate-detection used elsewhere in the
|
||||
* modal.
|
||||
*/
|
||||
function defaultDisplayName(
|
||||
userName: string | null | undefined,
|
||||
serviceName: string,
|
||||
takenNames: ReadonlySet<string>
|
||||
): string {
|
||||
const trimmed = userName?.trim()
|
||||
let base: string
|
||||
if (trimmed) {
|
||||
const suffix = `'s ${serviceName}`
|
||||
const nameBudget = Math.max(
|
||||
0,
|
||||
DISPLAY_NAME_MAX_LENGTH - suffix.length - COLLISION_SUFFIX_RESERVATION
|
||||
)
|
||||
const safeName = trimmed.length > nameBudget ? trimmed.slice(0, nameBudget) : trimmed
|
||||
base = `${safeName}${suffix}`
|
||||
} else {
|
||||
base = `My ${serviceName}`
|
||||
}
|
||||
|
||||
if (!takenNames.has(base.toLowerCase())) return base
|
||||
for (let n = 2; n < MAX_COLLISION_INDEX; n++) {
|
||||
const candidate = `${base} ${n}`
|
||||
if (!takenNames.has(candidate.toLowerCase())) return candidate
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the display name + icon for an OAuth `provider`/`serviceId` pair,
|
||||
* preferring the most specific service entry and falling back to the base
|
||||
* provider config, then to the raw provider id. Used when the caller does not
|
||||
* supply explicit `serviceName`/`serviceIcon`.
|
||||
*/
|
||||
function resolveService(
|
||||
provider: OAuthProvider,
|
||||
serviceId: string
|
||||
): { providerName: string; ProviderIcon: ServiceIcon } {
|
||||
const { baseProvider } = parseProvider(provider)
|
||||
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
|
||||
let providerName = baseProviderConfig?.name || provider
|
||||
let ProviderIcon: ServiceIcon = baseProviderConfig?.icon || (() => null)
|
||||
if (baseProviderConfig) {
|
||||
for (const [key, service] of Object.entries(baseProviderConfig.services)) {
|
||||
if (key === serviceId || service.providerId === provider) {
|
||||
providerName = service.name
|
||||
ProviderIcon = service.icon
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return { providerName, ProviderIcon }
|
||||
}
|
||||
|
||||
interface ConnectOAuthModalBaseProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/**
|
||||
* Canonical provider id (e.g. `google-email`). When omitted it is derived
|
||||
* from `serviceId`. Used for the credential draft and return context.
|
||||
*/
|
||||
providerId?: string
|
||||
/**
|
||||
* Optional explicit display name/icon. When omitted, both are resolved from
|
||||
* `provider` + `serviceId`. The integrations catalog supplies these directly;
|
||||
* workflow/KB callers rely on resolution.
|
||||
*/
|
||||
serviceName?: string
|
||||
serviceIcon?: ServiceIcon
|
||||
/** Used to resolve display metadata and the provider id when not supplied directly. */
|
||||
provider?: OAuthProvider
|
||||
serviceId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect mode. Creates the credential draft and writes the origin-specific
|
||||
* OAuth return context before handing off to the provider via
|
||||
* {@link useConnectOAuthService}.
|
||||
*/
|
||||
type ConnectOAuthModalConnectProps = ConnectOAuthModalBaseProps & {
|
||||
mode: 'connect'
|
||||
workspaceId: string
|
||||
requiredScopes: readonly string[]
|
||||
} & (
|
||||
| { origin: 'workflow'; workflowId: string }
|
||||
| { origin: 'kb-connectors'; knowledgeBaseId: string; connectorType?: string }
|
||||
| { origin: 'integrations' }
|
||||
)
|
||||
|
||||
/**
|
||||
* Reauthorize mode. Updates the scopes on an existing credential for
|
||||
* `toolName`. `newScopes` are surfaced with a "New" badge. An optional
|
||||
* `onConnect` override short-circuits the default provider hand-off.
|
||||
*/
|
||||
interface ConnectOAuthModalReauthorizeProps extends ConnectOAuthModalBaseProps {
|
||||
mode: 'reauthorize'
|
||||
toolName: string
|
||||
requiredScopes?: readonly string[]
|
||||
newScopes?: readonly string[]
|
||||
onConnect?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export type ConnectOAuthModalProps =
|
||||
| ConnectOAuthModalConnectProps
|
||||
| ConnectOAuthModalReauthorizeProps
|
||||
|
||||
/**
|
||||
* Unified connect/reauthorize OAuth credential modal (ChipModal UI). Mounted by
|
||||
* the integrations catalog, the workflow editor's credential selectors, and the
|
||||
* knowledge-base connector flows. After the redirect lands back on
|
||||
* `window.location.href`, the host page's OAuth return router consumes the
|
||||
* context written here.
|
||||
*/
|
||||
export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
|
||||
const { open, onOpenChange, mode } = props
|
||||
const isConnect = mode === 'connect'
|
||||
|
||||
const providerId = useMemo(
|
||||
() => props.providerId ?? (props.serviceId ? getProviderIdFromServiceId(props.serviceId) : ''),
|
||||
[props.providerId, props.serviceId]
|
||||
)
|
||||
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [validationError, setValidationError] = useState<string | null>(null)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
const { data: session } = useSession()
|
||||
const userName = session?.user?.name
|
||||
|
||||
const { providerName, ProviderIcon } = useMemo(() => {
|
||||
if (props.serviceName && props.serviceIcon) {
|
||||
return { providerName: props.serviceName, ProviderIcon: props.serviceIcon }
|
||||
}
|
||||
const provider = (props.provider ?? providerId) as OAuthProvider
|
||||
return resolveService(provider, props.serviceId ?? providerId)
|
||||
}, [props.serviceName, props.serviceIcon, props.provider, props.serviceId, providerId])
|
||||
|
||||
const workspaceId = isConnect ? props.workspaceId : ''
|
||||
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
|
||||
workspaceId,
|
||||
enabled: isConnect && Boolean(workspaceId) && open,
|
||||
})
|
||||
const createDraft = useCreateCredentialDraft()
|
||||
const connectOAuthService = useConnectOAuthService()
|
||||
|
||||
/**
|
||||
* Lowercased set of OAuth credential names already in the workspace. Drives
|
||||
* both the prefill's auto-numbering and the inline duplicate-name error.
|
||||
*/
|
||||
const takenNames = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
credentials
|
||||
.filter((credential) => credential.type === 'oauth')
|
||||
.map((credential) => credential.displayName.toLowerCase())
|
||||
),
|
||||
[credentials]
|
||||
)
|
||||
|
||||
const requiredScopes = props.requiredScopes ?? EMPTY_SCOPES
|
||||
const newScopes = !isConnect ? (props.newScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
|
||||
|
||||
const newScopesSet = useMemo(
|
||||
() => new Set([...newScopes].filter((scope) => !isHiddenScope(scope))),
|
||||
[newScopes]
|
||||
)
|
||||
|
||||
const displayScopes = useMemo(() => {
|
||||
const filtered = [...requiredScopes].filter((scope) => !isHiddenScope(scope))
|
||||
if (isConnect) return filtered
|
||||
return filtered.sort((a, b) => {
|
||||
const aIsNew = newScopesSet.has(a)
|
||||
const bIsNew = newScopesSet.has(b)
|
||||
if (aIsNew && !bIsNew) return -1
|
||||
if (!aIsNew && bIsNew) return 1
|
||||
return 0
|
||||
})
|
||||
}, [isConnect, requiredScopes, newScopesSet])
|
||||
|
||||
/**
|
||||
* Initialize the connect form once per open session, after credentials have
|
||||
* loaded so auto-numbering can see them. The `prefilled` ref ensures session
|
||||
* refetches or other prop churn while the modal is open won't overwrite the
|
||||
* user's typed value.
|
||||
*/
|
||||
const prefilled = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
prefilled.current = false
|
||||
return
|
||||
}
|
||||
if (!isConnect || prefilled.current || credentialsLoading) return
|
||||
prefilled.current = true
|
||||
setDisplayName(defaultDisplayName(userName, providerName, takenNames))
|
||||
setDescription('')
|
||||
setValidationError(null)
|
||||
setSubmitError(null)
|
||||
}, [open, isConnect, credentialsLoading, userName, providerName, takenNames])
|
||||
|
||||
const existingCredential = useMemo(() => {
|
||||
if (!isConnect) return null
|
||||
const name = displayName.trim().toLowerCase()
|
||||
if (!name || !takenNames.has(name)) return null
|
||||
return (
|
||||
credentials.find((row) => row.type === 'oauth' && row.displayName.toLowerCase() === name) ??
|
||||
null
|
||||
)
|
||||
}, [isConnect, credentials, displayName, takenNames])
|
||||
|
||||
const handleClose = () => {
|
||||
setSubmitError(null)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleConnect = async () => {
|
||||
setValidationError(null)
|
||||
setSubmitError(null)
|
||||
try {
|
||||
let connectorType: string | undefined
|
||||
|
||||
if (isConnect) {
|
||||
const trimmed = displayName.trim()
|
||||
if (!trimmed) {
|
||||
setValidationError('Display name is required.')
|
||||
return
|
||||
}
|
||||
|
||||
await createDraft.mutateAsync({
|
||||
workspaceId,
|
||||
providerId,
|
||||
displayName: trimmed,
|
||||
description: description.trim() || undefined,
|
||||
})
|
||||
|
||||
const preCount = credentials.filter(
|
||||
(c) => c.type === 'oauth' && c.providerId === providerId
|
||||
).length
|
||||
|
||||
const baseContext = {
|
||||
displayName: trimmed,
|
||||
providerId,
|
||||
preCount,
|
||||
workspaceId,
|
||||
requestedAt: Date.now(),
|
||||
}
|
||||
|
||||
let returnContext: OAuthReturnContext
|
||||
if (props.origin === 'kb-connectors') {
|
||||
connectorType = props.connectorType
|
||||
returnContext = {
|
||||
...baseContext,
|
||||
origin: 'kb-connectors',
|
||||
knowledgeBaseId: props.knowledgeBaseId,
|
||||
connectorType: props.connectorType,
|
||||
}
|
||||
} else if (props.origin === 'workflow') {
|
||||
returnContext = { ...baseContext, origin: 'workflow', workflowId: props.workflowId }
|
||||
} else {
|
||||
returnContext = { ...baseContext, origin: 'integrations' }
|
||||
}
|
||||
|
||||
writeOAuthReturnContext(returnContext)
|
||||
} else if (props.onConnect) {
|
||||
await props.onConnect()
|
||||
handleClose()
|
||||
return
|
||||
} else {
|
||||
logger.info('Reauthorizing OAuth2', {
|
||||
providerId,
|
||||
requiredScopes,
|
||||
hasNewScopes: newScopes.length > 0,
|
||||
})
|
||||
}
|
||||
|
||||
const callbackURL = new URL(window.location.href)
|
||||
if (connectorType) {
|
||||
callbackURL.searchParams.set(ADD_CONNECTOR_SEARCH_PARAM, connectorType)
|
||||
}
|
||||
|
||||
await connectOAuthService.mutateAsync({
|
||||
providerId,
|
||||
callbackURL: callbackURL.toString(),
|
||||
})
|
||||
handleClose()
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err, 'Failed to start OAuth connection')
|
||||
setSubmitError(message)
|
||||
logger.error('Failed to connect OAuth service', err)
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = (isConnect && createDraft.isPending) || connectOAuthService.isPending
|
||||
const isDisabled = isConnect
|
||||
? !displayName.trim() || isPending || Boolean(existingCredential)
|
||||
: isPending
|
||||
|
||||
/**
|
||||
* Submits the connect form on Enter, mirroring the Connect button's enabled
|
||||
* state and excluding the multi-line description. Restores the keyboard
|
||||
* affordance the pre-consolidation workflow modal provided.
|
||||
*/
|
||||
const handleBodyKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Enter' || !isConnect || isDisabled) return
|
||||
if (event.target instanceof HTMLTextAreaElement) return
|
||||
event.preventDefault()
|
||||
void handleConnect()
|
||||
}
|
||||
|
||||
const displayNameError =
|
||||
validationError ??
|
||||
(existingCredential
|
||||
? `An integration named "${existingCredential.displayName}" already exists.`
|
||||
: undefined)
|
||||
|
||||
const title = `Connect ${providerName}`
|
||||
|
||||
return (
|
||||
<ChipModal open={open} onOpenChange={onOpenChange} srTitle={title}>
|
||||
<ChipModalHeader icon={ProviderIcon} onClose={handleClose}>
|
||||
{title}
|
||||
</ChipModalHeader>
|
||||
<ChipModalBody onKeyDown={handleBodyKeyDown}>
|
||||
{!isConnect && (
|
||||
<p className='text-[var(--text-tertiary)] text-caption'>
|
||||
The "{props.toolName}" tool requires access to your account.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isConnect && (
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Display name'
|
||||
value={displayName}
|
||||
onChange={(value) => {
|
||||
setDisplayName(value)
|
||||
if (validationError) setValidationError(null)
|
||||
}}
|
||||
placeholder='Integration name'
|
||||
autoComplete='off'
|
||||
required
|
||||
error={displayNameError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isConnect && (
|
||||
<ChipModalField
|
||||
type='textarea'
|
||||
title='Description'
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder='Optional description'
|
||||
maxLength={500}
|
||||
minHeight={80}
|
||||
/>
|
||||
)}
|
||||
|
||||
{displayScopes.length > 0 && (
|
||||
<ChipModalField type='custom' title='Permissions requested'>
|
||||
<InfoCard>
|
||||
<InfoCardList>
|
||||
{displayScopes.map((scope) => (
|
||||
<InfoCardItem key={scope}>
|
||||
<span className='flex items-center gap-2'>
|
||||
{getScopeDescription(scope)}
|
||||
{!isConnect && newScopesSet.has(scope) && (
|
||||
<Badge variant='amber' size='sm'>
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</InfoCardItem>
|
||||
))}
|
||||
</InfoCardList>
|
||||
</InfoCard>
|
||||
</ChipModalField>
|
||||
)}
|
||||
|
||||
<ChipModalError>{submitError}</ChipModalError>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={handleClose}
|
||||
cancelDisabled={isPending}
|
||||
primaryAction={{
|
||||
label: isPending ? 'Connecting...' : 'Connect',
|
||||
onClick: handleConnect,
|
||||
disabled: isDisabled,
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
ConnectOAuthModal,
|
||||
type ConnectOAuthModalProps,
|
||||
} from './connect-oauth-modal'
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
|
||||
interface ConversationListItemProps {
|
||||
title: string
|
||||
isActive?: boolean
|
||||
isUnread?: boolean
|
||||
className?: string
|
||||
titleClassName?: string
|
||||
statusIndicatorClassName?: string
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
export function ConversationListItem({
|
||||
title,
|
||||
isActive = false,
|
||||
isUnread = false,
|
||||
className,
|
||||
titleClassName,
|
||||
statusIndicatorClassName,
|
||||
actions,
|
||||
}: ConversationListItemProps) {
|
||||
const showStatusDot = isActive || isUnread
|
||||
return (
|
||||
<div className={cn('flex w-full min-w-0 items-center gap-2', className)}>
|
||||
<span className={cn('min-w-0 flex-1 truncate', titleClassName)}>{title}</span>
|
||||
{showStatusDot && (
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className={cn('size-[6px] flex-shrink-0 rounded-full', statusIndicatorClassName)}
|
||||
style={{
|
||||
backgroundColor: isActive ? '#EAB308' : 'var(--brand-accent)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{actions && <div className='ml-auto flex flex-shrink-0 items-center'>{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ConversationListItem } from './conversation-list-item'
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
} from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
useUpsertWorkspaceCredentialMember,
|
||||
useWorkspaceCredentialMembers,
|
||||
type WorkspaceCredentialRole,
|
||||
} from '@/hooks/queries/credentials'
|
||||
import { ROLE_OPTIONS } from '../roles'
|
||||
import { partitionSettledFailures, resolveAddEmail } from '../sharing'
|
||||
|
||||
const logger = createLogger('AddPeopleModal')
|
||||
|
||||
interface AddPeopleModalProps {
|
||||
credentialId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "Add people" modal: grants existing workspace members access to a
|
||||
* credential with a chosen role. Emails are validated against the workspace
|
||||
* roster and current membership; each add is an idempotent upsert and partial
|
||||
* failures keep only the people that still need adding.
|
||||
*/
|
||||
export function AddPeopleModal({ credentialId, open, onOpenChange }: AddPeopleModalProps) {
|
||||
const { workspacePermissions } = useWorkspacePermissionsContext()
|
||||
const { data: members = [] } = useWorkspaceCredentialMembers(credentialId)
|
||||
const upsertMember = useUpsertWorkspaceCredentialMember()
|
||||
|
||||
const [emailsToAdd, setEmailsToAdd] = useState<string[]>([])
|
||||
const [roleToAdd, setRoleToAdd] = useState<WorkspaceCredentialRole>('member')
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
const workspaceUserIdByEmail = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(workspacePermissions?.users ?? []).map((user) => [user.email.toLowerCase(), user.userId])
|
||||
),
|
||||
[workspacePermissions?.users]
|
||||
)
|
||||
|
||||
const existingMemberEmails = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
members
|
||||
.filter((member) => member.status === 'active')
|
||||
.map((member) => (member.userEmail ?? '').toLowerCase())
|
||||
.filter(Boolean)
|
||||
),
|
||||
[members]
|
||||
)
|
||||
|
||||
const validateAddEmail = useCallback(
|
||||
(email: string): string | null => {
|
||||
const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails })
|
||||
return 'error' in result ? result.error : null
|
||||
},
|
||||
[workspaceUserIdByEmail, existingMemberEmails]
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setEmailsToAdd([])
|
||||
setRoleToAdd('member')
|
||||
setSubmitError(null)
|
||||
onOpenChange(false)
|
||||
}, [onOpenChange])
|
||||
|
||||
const handleAddPeople = useCallback(async () => {
|
||||
if (emailsToAdd.length === 0 || isAdding) return
|
||||
setSubmitError(null)
|
||||
const targets = emailsToAdd
|
||||
.map((email) => {
|
||||
const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails })
|
||||
return 'userId' in result ? { email, userId: result.userId } : null
|
||||
})
|
||||
.filter((target): target is { email: string; userId: string } => target !== null)
|
||||
if (targets.length === 0) return
|
||||
|
||||
setIsAdding(true)
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
targets.map((target) =>
|
||||
upsertMember.mutateAsync({ credentialId, userId: target.userId, role: roleToAdd })
|
||||
)
|
||||
)
|
||||
const failures = partitionSettledFailures(targets, results)
|
||||
if (failures.length === 0) {
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
setEmailsToAdd(failures.map((target) => target.email))
|
||||
const firstError = results.find(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected'
|
||||
)
|
||||
logger.error('Failed to add some credential members', firstError?.reason)
|
||||
const reason = getErrorMessage(firstError?.reason, 'Please try again in a moment.')
|
||||
setSubmitError(
|
||||
failures.length === targets.length
|
||||
? `Couldn't add people. ${reason}`
|
||||
: `Couldn't add ${failures.length} of ${targets.length} people. ${reason}`
|
||||
)
|
||||
} finally {
|
||||
setIsAdding(false)
|
||||
}
|
||||
}, [
|
||||
credentialId,
|
||||
emailsToAdd,
|
||||
isAdding,
|
||||
workspaceUserIdByEmail,
|
||||
existingMemberEmails,
|
||||
roleToAdd,
|
||||
upsertMember,
|
||||
handleClose,
|
||||
])
|
||||
|
||||
return (
|
||||
<ChipModal
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) handleClose()
|
||||
}}
|
||||
srTitle='Add people'
|
||||
>
|
||||
<ChipModalHeader onClose={handleClose}>Add people</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipModalField
|
||||
type='emails'
|
||||
title='Emails'
|
||||
value={emailsToAdd}
|
||||
onChange={setEmailsToAdd}
|
||||
validate={validateAddEmail}
|
||||
placeholder='Enter emails'
|
||||
disabled={isAdding}
|
||||
/>
|
||||
<ChipModalField
|
||||
type='dropdown'
|
||||
title='Role'
|
||||
options={ROLE_OPTIONS}
|
||||
value={roleToAdd}
|
||||
placeholder='Select role'
|
||||
align='start'
|
||||
onChange={(role) => setRoleToAdd(role as WorkspaceCredentialRole)}
|
||||
disabled={isAdding}
|
||||
/>
|
||||
<ChipModalError>{submitError}</ChipModalError>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={handleClose}
|
||||
primaryAction={{
|
||||
label: isAdding ? 'Adding...' : 'Add',
|
||||
onClick: handleAddPeople,
|
||||
disabled: emailsToAdd.length === 0 || isAdding,
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { chipFieldSurfaceClass, chipFieldTextClass, cn } from '@sim/emcn'
|
||||
|
||||
/** Pill wrapper. Override height/alignment (e.g. a textarea) via `cn`. */
|
||||
export const CHIP_FIELD_SHELL = cn('flex h-[30px] items-center gap-1.5 px-2', chipFieldSurfaceClass)
|
||||
|
||||
/** Borderless input/textarea hosted inside {@link CHIP_FIELD_SHELL}. */
|
||||
export const CHIP_FIELD_INPUT = cn('h-full w-full bg-transparent', chipFieldTextClass)
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface CredentialDetailHeadingProps {
|
||||
/** Leading visual (icon tile or brand tile). */
|
||||
leading: ReactNode
|
||||
title: ReactNode
|
||||
subtitle?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Header row shared by credential detail surfaces: a leading visual beside a
|
||||
* title over a muted subtitle.
|
||||
*/
|
||||
export function CredentialDetailHeading({
|
||||
leading,
|
||||
title,
|
||||
subtitle,
|
||||
}: CredentialDetailHeadingProps) {
|
||||
return (
|
||||
<div className='flex items-center gap-2.5'>
|
||||
{leading}
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<span className='truncate text-[14px] text-[var(--text-body)]'>{title}</span>
|
||||
{subtitle ? (
|
||||
<span className='truncate text-[12px] text-[var(--text-muted)]'>{subtitle}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface CredentialDetailLayoutProps {
|
||||
/** Back link rendered at the start of the fixed action bar. */
|
||||
back: ReactNode
|
||||
/** Optional controls grouped at the end of the action bar. */
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Page shell shared by the credential detail surfaces: a fixed action bar
|
||||
* (back link + grouped actions) above a scrollable, centered body. Surfaces
|
||||
* supply the slots and body sections; all layout chrome lives here so callsites
|
||||
* stay free of bespoke styling.
|
||||
*/
|
||||
export function CredentialDetailLayout({ back, actions, children }: CredentialDetailLayoutProps) {
|
||||
return (
|
||||
<div className='flex h-full flex-col bg-[var(--bg)]'>
|
||||
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
|
||||
{back}
|
||||
{actions ? <div className='flex items-center'>{actions}</div> : null}
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
|
||||
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
import { Avatar, AvatarFallback, Chip, ChipDropdown, cn } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { credentialRoleLockReason, RoleLockTooltip } from '@/components/permissions'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import {
|
||||
useRemoveWorkspaceCredentialMember,
|
||||
useUpsertWorkspaceCredentialMember,
|
||||
useWorkspaceCredentialMembers,
|
||||
type WorkspaceCredentialRole,
|
||||
} from '@/hooks/queries/credentials'
|
||||
import { ROLE_OPTIONS } from '../roles'
|
||||
import { DetailSection } from './detail-section'
|
||||
|
||||
const logger = createLogger('CredentialMembersSection')
|
||||
|
||||
interface CredentialMembersSectionProps {
|
||||
credentialId: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Active-member list for a credential: avatar + identity, a role dropdown, and a
|
||||
* remove action. The last remaining admin cannot be demoted or removed. Shared
|
||||
* by every credential detail surface.
|
||||
*/
|
||||
export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMembersSectionProps) {
|
||||
const { data: members = [], isPending: membersLoading } =
|
||||
useWorkspaceCredentialMembers(credentialId)
|
||||
const upsertMember = useUpsertWorkspaceCredentialMember()
|
||||
const removeMember = useRemoveWorkspaceCredentialMember()
|
||||
|
||||
const activeMembers = members.filter((member) => member.status === 'active')
|
||||
const explicitAdminCount = activeMembers.filter(
|
||||
(member) => member.role === 'admin' && member.roleSource !== 'workspace-admin'
|
||||
).length
|
||||
|
||||
const handleChangeMemberRole = async (userId: string, role: WorkspaceCredentialRole) => {
|
||||
const current = activeMembers.find((member) => member.userId === userId)
|
||||
if (current?.role === role) return
|
||||
try {
|
||||
await upsertMember.mutateAsync({ credentialId, userId, role })
|
||||
} catch (error) {
|
||||
logger.error('Failed to change member role', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
try {
|
||||
await removeMember.mutateAsync({ credentialId, userId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove credential member', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DetailSection title={`Members (${activeMembers.length})`}>
|
||||
{membersLoading ? null : (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{activeMembers.map((member) => {
|
||||
const lockReason = credentialRoleLockReason(member.roleSource)
|
||||
const roleLocked =
|
||||
member.role === 'admin' &&
|
||||
member.roleSource !== 'workspace-admin' &&
|
||||
explicitAdminCount <= 1
|
||||
const roleDisabled = !isAdmin || roleLocked || lockReason !== null
|
||||
const removeDisabled = roleLocked || lockReason !== null
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className={cn(
|
||||
'grid items-center gap-2',
|
||||
isAdmin ? 'grid-cols-[1fr_120px_72px]' : 'grid-cols-[1fr_200px]'
|
||||
)}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2.5'>
|
||||
<Avatar className='size-9 flex-shrink-0'>
|
||||
<AvatarFallback
|
||||
style={{
|
||||
background: getUserColor(member.userId || member.userEmail || ''),
|
||||
}}
|
||||
className='border border-[var(--border-1)] text-small text-white'
|
||||
>
|
||||
{(member.userName || member.userEmail || '?').charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
<span className='truncate text-[14px] text-[var(--text-body)]'>
|
||||
{member.userName || member.userEmail || member.userId}
|
||||
</span>
|
||||
<span className='truncate text-[12px] text-[var(--text-muted)]'>
|
||||
{member.userEmail || member.userId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<RoleLockTooltip reason={lockReason}>
|
||||
<ChipDropdown
|
||||
options={ROLE_OPTIONS}
|
||||
value={member.role}
|
||||
placeholder='Role'
|
||||
disabled={roleDisabled}
|
||||
onChange={(role) =>
|
||||
handleChangeMemberRole(member.userId, role as WorkspaceCredentialRole)
|
||||
}
|
||||
/>
|
||||
</RoleLockTooltip>
|
||||
{isAdmin && (
|
||||
<Chip
|
||||
onClick={() => handleRemoveMember(member.userId)}
|
||||
disabled={removeDisabled}
|
||||
flush
|
||||
className='justify-self-end'
|
||||
>
|
||||
Remove
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DetailSection>
|
||||
)
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
interface DetailIconTileProps {
|
||||
icon: ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
/** Square tile with a centered icon, used as a detail header's leading visual. */
|
||||
export function DetailIconTile({ icon: Icon }: DetailIconTileProps) {
|
||||
return (
|
||||
<div className='flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-[var(--surface-5)]'>
|
||||
<Icon className='size-[18px] text-[var(--text-tertiary)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface DetailSectionProps {
|
||||
title: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Labeled section with a muted title and a thin inset divider above the body.
|
||||
* Shared by the credential detail surfaces so every section keeps the same
|
||||
* vertical rhythm without repeating markup at the callsites.
|
||||
*/
|
||||
export function DetailSection({ title, children }: DetailSectionProps) {
|
||||
return (
|
||||
<section className='flex flex-col'>
|
||||
<span className='pl-0.5 text-[var(--text-muted)] text-small'>{title}</span>
|
||||
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { ChipConfirmModal } from '@sim/emcn'
|
||||
|
||||
interface UnsavedChangesModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Confirmed discard: abandon edits and continue (navigate away / close). */
|
||||
onDiscard: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation shown when leaving a surface with unsaved edits. Shared by the
|
||||
* credential detail surfaces and the secrets list so the copy and affordances
|
||||
* stay identical.
|
||||
*/
|
||||
export function UnsavedChangesModal({ open, onOpenChange, onDiscard }: UnsavedChangesModalProps) {
|
||||
return (
|
||||
<ChipConfirmModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
srTitle='Unsaved Changes'
|
||||
title='Unsaved Changes'
|
||||
text='You have unsaved changes. Are you sure you want to discard them?'
|
||||
dismissLabel='Keep editing'
|
||||
confirm={{ label: 'Discard Changes', onClick: onDiscard }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useUpdateWorkspaceCredential, type WorkspaceCredential } from '@/hooks/queries/credentials'
|
||||
import { useUnsavedChangesGuard } from './use-unsaved-changes-guard'
|
||||
|
||||
const logger = createLogger('CredentialDetailForm')
|
||||
|
||||
interface UseCredentialDetailFormParams {
|
||||
credential: WorkspaceCredential | null
|
||||
isAdmin: boolean
|
||||
/** Where the back link / discard navigates to. */
|
||||
backHref: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared editable-metadata controller for a credential detail page: Display Name
|
||||
* and Description drafts seeded from the credential, dirty tracking, an
|
||||
* admin-only save, and the shared unsaved-changes guard.
|
||||
*/
|
||||
export function useCredentialDetailForm({
|
||||
credential,
|
||||
isAdmin,
|
||||
backHref,
|
||||
}: UseCredentialDetailFormParams) {
|
||||
const updateCredential = useUpdateWorkspaceCredential()
|
||||
|
||||
const [displayNameDraft, setDisplayNameDraft] = useState('')
|
||||
const [descriptionDraft, setDescriptionDraft] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayNameDraft(credential?.displayName ?? '')
|
||||
setDescriptionDraft(credential?.description ?? '')
|
||||
}, [credential?.id, credential?.displayName, credential?.description])
|
||||
|
||||
const isDisplayNameDirty = credential ? displayNameDraft !== credential.displayName : false
|
||||
const isDescriptionDirty = credential
|
||||
? descriptionDraft !== (credential.description || '')
|
||||
: false
|
||||
const isDirty = isDisplayNameDirty || isDescriptionDirty
|
||||
|
||||
const guard = useUnsavedChangesGuard({ isDirty, backHref })
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!credential || !isAdmin || !isDirty || updateCredential.isPending) return
|
||||
try {
|
||||
await updateCredential.mutateAsync({
|
||||
credentialId: credential.id,
|
||||
...(isDisplayNameDirty ? { displayName: displayNameDraft.trim() } : {}),
|
||||
...(isDescriptionDirty ? { description: descriptionDraft.trim() || null } : {}),
|
||||
})
|
||||
if (isDisplayNameDirty) setDisplayNameDraft((value) => value.trim())
|
||||
if (isDescriptionDirty) setDescriptionDraft((value) => value.trim())
|
||||
} catch (error) {
|
||||
toast.error("Couldn't save changes", {
|
||||
description: getErrorMessage(error, 'Please try again in a moment.'),
|
||||
})
|
||||
logger.error('Failed to save credential details', error)
|
||||
}
|
||||
}, [
|
||||
credential,
|
||||
isAdmin,
|
||||
isDirty,
|
||||
isDisplayNameDirty,
|
||||
isDescriptionDirty,
|
||||
displayNameDraft,
|
||||
descriptionDraft,
|
||||
updateCredential,
|
||||
])
|
||||
|
||||
return {
|
||||
displayNameDraft,
|
||||
setDisplayNameDraft,
|
||||
descriptionDraft,
|
||||
setDescriptionDraft,
|
||||
isDirty,
|
||||
save,
|
||||
isSaving: updateCredential.isPending,
|
||||
handleBackClick: guard.handleBackClick,
|
||||
showUnsavedAlert: guard.showUnsavedAlert,
|
||||
setShowUnsavedAlert: guard.setShowUnsavedAlert,
|
||||
confirmDiscard: guard.confirmDiscard,
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import type { MouseEvent } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface UseUnsavedChangesGuardParams {
|
||||
isDirty: boolean
|
||||
/** Where a confirmed discard navigates to. */
|
||||
backHref: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards a detail surface against losing unsaved edits: warns on browser unload
|
||||
* while dirty, intercepts the in-app back link, and traps the browser
|
||||
* back/forward button to confirm before leaving. Shared by every credential
|
||||
* detail surface so the behavior is identical.
|
||||
*
|
||||
* Native Back is trapped by seeding a single same-URL history entry while dirty,
|
||||
* so Back pops that entry (no route change) and fires popstate. The seed is
|
||||
* removed once the form is clean again (save/revert) so it never accumulates
|
||||
* across edit cycles — and that removal runs in the effect body (only while
|
||||
* still mounted), never in cleanup, so an intentional discard/navigation away is
|
||||
* not reversed.
|
||||
*/
|
||||
export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesGuardParams) {
|
||||
const router = useRouter()
|
||||
const [showUnsavedAlert, setShowUnsavedAlert] = useState(false)
|
||||
const hasSentinelRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) {
|
||||
// Clean again while still mounted (saved/reverted): pop the seeded entry so
|
||||
// it can't pile up across edit/save cycles. This runs in the effect body,
|
||||
// never on unmount, so navigating away mid-edit is never reversed.
|
||||
if (hasSentinelRef.current) {
|
||||
hasSentinelRef.current = false
|
||||
window.history.back()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Seed one same-URL entry so Back pops it (no route change) and fires
|
||||
// popstate, letting us confirm before the page actually leaves.
|
||||
if (!hasSentinelRef.current) {
|
||||
window.history.pushState(null, '', window.location.href)
|
||||
hasSentinelRef.current = true
|
||||
}
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
const handlePopState = () => {
|
||||
window.history.pushState(null, '', window.location.href)
|
||||
setShowUnsavedAlert(true)
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
window.removeEventListener('popstate', handlePopState)
|
||||
}
|
||||
}, [isDirty])
|
||||
|
||||
const handleBackClick = useCallback(
|
||||
(event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (isDirty) {
|
||||
event.preventDefault()
|
||||
setShowUnsavedAlert(true)
|
||||
}
|
||||
},
|
||||
[isDirty]
|
||||
)
|
||||
|
||||
const confirmDiscard = useCallback(() => {
|
||||
setShowUnsavedAlert(false)
|
||||
router.push(backHref)
|
||||
}, [router, backHref])
|
||||
|
||||
return { showUnsavedAlert, setShowUnsavedAlert, handleBackClick, confirmDiscard }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { AddPeopleModal } from './components/add-people-modal'
|
||||
export { CHIP_FIELD_INPUT, CHIP_FIELD_SHELL } from './components/chip-field'
|
||||
export { CredentialDetailHeading } from './components/credential-detail-heading'
|
||||
export { CredentialDetailLayout } from './components/credential-detail-layout'
|
||||
export { CredentialMembersSection } from './components/credential-members-section'
|
||||
export { DetailIconTile } from './components/detail-icon-tile'
|
||||
export { DetailSection } from './components/detail-section'
|
||||
export { UnsavedChangesModal } from './components/unsaved-changes-modal'
|
||||
export { useCredentialDetailForm } from './hooks/use-credential-detail-form'
|
||||
export { useUnsavedChangesGuard } from './hooks/use-unsaved-changes-guard'
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { WorkspaceCredentialRole } from '@/hooks/queries/credentials'
|
||||
|
||||
export interface CredentialRoleOption {
|
||||
value: WorkspaceCredentialRole
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Roles assignable to a credential member. Shared by every credential detail
|
||||
* surface (Integrations, Secrets) so role choices never drift between them.
|
||||
*/
|
||||
export const ROLE_OPTIONS: readonly CredentialRoleOption[] = [
|
||||
{ value: 'member', label: 'Member' },
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
] as const
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { partitionSettledFailures, resolveAddEmail } from './sharing'
|
||||
|
||||
describe('resolveAddEmail', () => {
|
||||
const ctx = {
|
||||
workspaceUserIdByEmail: new Map([
|
||||
['ada@sim.dev', 'user-ada'],
|
||||
['grace@sim.dev', 'user-grace'],
|
||||
]),
|
||||
existingMemberEmails: new Set(['grace@sim.dev']),
|
||||
}
|
||||
|
||||
it('returns the userId for a workspace member who is not already on the credential', () => {
|
||||
expect(resolveAddEmail('ada@sim.dev', ctx)).toEqual({ userId: 'user-ada' })
|
||||
})
|
||||
|
||||
it('rejects an email that does not belong to any workspace member', () => {
|
||||
expect(resolveAddEmail('nope@sim.dev', ctx)).toEqual({
|
||||
error: "nope@sim.dev isn't a member of this workspace",
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an email that already has access to the credential', () => {
|
||||
expect(resolveAddEmail('grace@sim.dev', ctx)).toEqual({
|
||||
error: 'grace@sim.dev already has access',
|
||||
})
|
||||
})
|
||||
|
||||
it('matches case-insensitively while echoing the original email in errors', () => {
|
||||
expect(resolveAddEmail('ADA@Sim.dev', ctx)).toEqual({ userId: 'user-ada' })
|
||||
expect(resolveAddEmail('Grace@SIM.dev', ctx)).toEqual({
|
||||
error: 'Grace@SIM.dev already has access',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('partitionSettledFailures', () => {
|
||||
const targets = [{ email: 'a' }, { email: 'b' }, { email: 'c' }]
|
||||
|
||||
it('returns no failures when every result is fulfilled', () => {
|
||||
const results: PromiseSettledResult<unknown>[] = [
|
||||
{ status: 'fulfilled', value: 1 },
|
||||
{ status: 'fulfilled', value: 2 },
|
||||
{ status: 'fulfilled', value: 3 },
|
||||
]
|
||||
expect(partitionSettledFailures(targets, results)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns only the rejected targets (index-aligned) on partial failure', () => {
|
||||
const results: PromiseSettledResult<unknown>[] = [
|
||||
{ status: 'fulfilled', value: 1 },
|
||||
{ status: 'rejected', reason: new Error('boom') },
|
||||
{ status: 'fulfilled', value: 3 },
|
||||
]
|
||||
expect(partitionSettledFailures(targets, results)).toEqual([{ email: 'b' }])
|
||||
})
|
||||
|
||||
it('returns all targets when every result rejected', () => {
|
||||
const results: PromiseSettledResult<unknown>[] = [
|
||||
{ status: 'rejected', reason: new Error('1') },
|
||||
{ status: 'rejected', reason: new Error('2') },
|
||||
{ status: 'rejected', reason: new Error('3') },
|
||||
]
|
||||
expect(partitionSettledFailures(targets, results)).toEqual(targets)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface ResolveAddEmailContext {
|
||||
/** Lowercased email -> workspace userId for every workspace member. */
|
||||
workspaceUserIdByEmail: Map<string, string>
|
||||
/** Lowercased emails that already have access to the credential. */
|
||||
existingMemberEmails: Set<string>
|
||||
}
|
||||
|
||||
export type ResolveAddEmailResult = { userId: string } | { error: string }
|
||||
|
||||
/**
|
||||
* Decide whether a format-valid email can be added to a credential: it must
|
||||
* belong to a workspace member and not already have access. Matching is
|
||||
* case-insensitive (the context map/set are keyed by lowercased email) while
|
||||
* error messages echo the email as the user typed it. Returns the resolved
|
||||
* `userId` on success, or a user-facing `error` message.
|
||||
*/
|
||||
export function resolveAddEmail(
|
||||
email: string,
|
||||
{ workspaceUserIdByEmail, existingMemberEmails }: ResolveAddEmailContext
|
||||
): ResolveAddEmailResult {
|
||||
const normalized = email.toLowerCase()
|
||||
const userId = workspaceUserIdByEmail.get(normalized)
|
||||
if (!userId) return { error: `${email} isn't a member of this workspace` }
|
||||
if (existingMemberEmails.has(normalized)) return { error: `${email} already has access` }
|
||||
return { userId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the targets passed to a batch add and the index-aligned
|
||||
* `Promise.allSettled` results, return the targets whose settle rejected.
|
||||
*/
|
||||
export function partitionSettledFailures<T>(
|
||||
targets: readonly T[],
|
||||
results: readonly PromiseSettledResult<unknown>[]
|
||||
): T[] {
|
||||
return targets.filter((_, index) => results[index]?.status === 'rejected')
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
|
||||
interface DropZoneProps {
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** File drop target with a dashed accent overlay while dragging. Shared by the
|
||||
* whitelabeling settings and the deploy-as-block icon upload. */
|
||||
export function DropZone({ onDrop, children, className }: DropZoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative', className)}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDragging(false)
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
setIsDragging(false)
|
||||
onDrop(e)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{isDragging && (
|
||||
<div className='pointer-events-none absolute inset-0 z-10 rounded-lg border-[1.5px] border-[var(--brand-accent)] border-dashed bg-[color-mix(in_srgb,var(--brand-accent)_8%,transparent)]' />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useEffect } from 'react'
|
||||
import { Button } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
|
||||
/** Props shape required by Next.js error boundary files (`error.tsx`). */
|
||||
export interface ErrorBoundaryProps {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export interface ErrorStateProps extends ErrorBoundaryProps {
|
||||
title: string
|
||||
description: string
|
||||
loggerName: string
|
||||
/** Optional glyph for the framed mark. Defaults to `TriangleAlert`. */
|
||||
icon?: ReactNode
|
||||
/** Extra action buttons rendered before the default "Try again". */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
interface ErrorShellProps {
|
||||
title: string
|
||||
description: string
|
||||
icon?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered layout shared by the workspace error boundary and not-found page.
|
||||
* Renders a framed glyph, serif headline, supporting paragraph, and a row of
|
||||
* action buttons.
|
||||
*/
|
||||
export function ErrorShell({ title, description, icon, children }: ErrorShellProps) {
|
||||
return (
|
||||
<div className='flex h-full flex-1 items-center justify-center bg-[var(--bg)] px-6 py-12'>
|
||||
<div className='flex w-full max-w-[420px] flex-col items-center gap-5 text-center'>
|
||||
<div className='size-[52px] shrink-0 rounded-2xl border border-[var(--border-muted)] bg-[var(--surface-4)] p-[3px] shadow-sm dark:bg-[var(--surface-5)]'>
|
||||
<div className='flex size-full items-center justify-center rounded-[11px] border border-[var(--border-1)] bg-[var(--bg)] text-[var(--text-icon)]'>
|
||||
{icon ?? <TriangleAlert className='size-[22px]' strokeWidth={1.55} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col items-center gap-2'>
|
||||
<h2 className='text-balance font-[430] font-season text-[26px] text-[var(--text-primary)] leading-[1.15] tracking-[-0.01em] sm:text-[28px]'>
|
||||
{title}
|
||||
</h2>
|
||||
<p className='max-w-[340px] text-[14px] text-[var(--text-tertiary)] leading-[1.55]'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center justify-center gap-2 pt-1'>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace error boundary view. Logs the error once per occurrence and renders
|
||||
* `ErrorShell` with a primary "Try again" action. Pass extra buttons (e.g. "Go
|
||||
* back") via `children` — they render before the "Try again" button.
|
||||
*/
|
||||
export function ErrorState({
|
||||
error,
|
||||
reset,
|
||||
title,
|
||||
description,
|
||||
loggerName,
|
||||
icon,
|
||||
children,
|
||||
}: ErrorStateProps) {
|
||||
useEffect(() => {
|
||||
createLogger(loggerName).error(`${loggerName} error:`, {
|
||||
error: error.message,
|
||||
digest: error.digest,
|
||||
})
|
||||
}, [error.message, error.digest, loggerName])
|
||||
|
||||
return (
|
||||
<ErrorShell title={title} description={description} icon={icon}>
|
||||
{children}
|
||||
<Button variant='primary' size='md' onClick={reset}>
|
||||
Refresh
|
||||
</Button>
|
||||
</ErrorShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ErrorBoundaryProps, ErrorStateProps } from './error'
|
||||
export { ErrorShell, ErrorState } from './error'
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Banner } from '@sim/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useStopImpersonating } from '@/hooks/queries/admin-users'
|
||||
|
||||
function getImpersonationBannerText(userLabel: string, userEmail?: string) {
|
||||
return `Impersonating ${userLabel}${userEmail ? ` (${userEmail})` : ''}. Changes will apply to this account until you switch back.`
|
||||
}
|
||||
|
||||
export function ImpersonationBanner() {
|
||||
const { data: session, isPending } = useSession()
|
||||
const stopImpersonating = useStopImpersonating()
|
||||
const [isRedirecting, setIsRedirecting] = useState(false)
|
||||
const userLabel = session?.user?.name || 'this user'
|
||||
const userEmail = session?.user?.email
|
||||
|
||||
if (isPending || !session?.session?.impersonatedBy) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Banner
|
||||
variant='destructive'
|
||||
text={getImpersonationBannerText(userLabel, userEmail)}
|
||||
textClassName='text-red-700 dark:text-red-300'
|
||||
actionLabel={
|
||||
stopImpersonating.isPending || isRedirecting ? 'Returning...' : 'Stop impersonating'
|
||||
}
|
||||
actionVariant='destructive'
|
||||
actionDisabled={stopImpersonating.isPending || isRedirecting}
|
||||
onAction={() =>
|
||||
stopImpersonating.mutate(undefined, {
|
||||
onError: () => {
|
||||
setIsRedirecting(false)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsRedirecting(true)
|
||||
window.location.assign('/workspace')
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ImpersonationBanner } from './impersonation-banner'
|
||||
@@ -0,0 +1,39 @@
|
||||
export { ConversationListItem } from './conversation-list-item'
|
||||
export type { ErrorBoundaryProps, ErrorStateProps } from './error'
|
||||
export { ErrorShell, ErrorState } from './error'
|
||||
export { InlineRenameInput } from './inline-rename-input'
|
||||
export { MessageActions } from './message-actions'
|
||||
export { FloatingOverflowText } from './resource/components/floating-overflow-text'
|
||||
export { ownerCell } from './resource/components/owner-cell'
|
||||
export {
|
||||
type ChromeActionSpec,
|
||||
ResourceChromeFallback,
|
||||
} from './resource/components/resource-chrome-fallback'
|
||||
export type {
|
||||
BreadcrumbEditing,
|
||||
BreadcrumbItem,
|
||||
DropdownOption,
|
||||
ResourceAction,
|
||||
} from './resource/components/resource-header'
|
||||
export type {
|
||||
ColumnOption,
|
||||
FilterConfig,
|
||||
FilterTag,
|
||||
SearchConfig,
|
||||
SearchTag,
|
||||
SortConfig,
|
||||
} from './resource/components/resource-options'
|
||||
export { SortDropdown } from './resource/components/resource-options'
|
||||
export { timeCell } from './resource/components/time-cell'
|
||||
export type {
|
||||
PaginationConfig,
|
||||
ResourceCell,
|
||||
ResourceCellEditing,
|
||||
ResourceColumn,
|
||||
ResourceRow,
|
||||
ResourceTableHandle,
|
||||
RowDragDropConfig,
|
||||
SelectableConfig,
|
||||
} from './resource/resource'
|
||||
export { EMPTY_CELL_PLACEHOLDER, Resource } from './resource/resource'
|
||||
export { SkillTile } from './skill-tile'
|
||||
@@ -0,0 +1 @@
|
||||
export { InlineRenameInput } from './inline-rename-input'
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface InlineRenameInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
onCancel: () => void
|
||||
/**
|
||||
* Disables the field while the rename is in flight, mirroring the sidebar's
|
||||
* `disabled={isRenaming}`. Threaded from `useInlineRename`'s `isSaving`.
|
||||
*/
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline rename field used by every resource rename surface (tables, files,
|
||||
* knowledge), triggered from a context menu. Matches the sidebar workflow rename
|
||||
* (`useItemRename`) input verbatim: same focus-reset className and attribute set
|
||||
* (`maxLength`, autocomplete/correct/capitalize off, spellCheck off), focus +
|
||||
* select on mount, commit on blur, Enter to submit, Escape to cancel, disabled
|
||||
* while saving. The only intentional delta is the table-cell `size={...}`
|
||||
* auto-width, which the sidebar (full-width row) does not need.
|
||||
*
|
||||
* The triggering menu uses `onCloseAutoFocus={(e) => e.preventDefault()}`, so the
|
||||
* Radix focus-scope teardown never steals focus back from this freshly-focused
|
||||
* input. `onSubmit` (from `useInlineRename`) is idempotent via its `doneRef`
|
||||
* guard, so a blur racing an Enter/Escape commit is a harmless no-op.
|
||||
*
|
||||
* TODO: the resource rename still intermittently unfocuses; the deeper re-render
|
||||
* cause (parent remounting the editing cell) is tracked separately. This input is
|
||||
* aligned to the proven sidebar pattern as the first step.
|
||||
*/
|
||||
export function InlineRenameInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
disabled = false,
|
||||
}: InlineRenameInputProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
el.focus()
|
||||
el.select()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
value={value}
|
||||
size={Math.max(value.length + 2, 5)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onSubmit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='w-full min-w-0 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
maxLength={100}
|
||||
disabled={disabled}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MessageActions } from './message-actions'
|
||||
@@ -0,0 +1,294 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Check,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
cn,
|
||||
Duplicate,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
Tooltip,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
import { GitBranch } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
|
||||
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
|
||||
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
|
||||
const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file'
|
||||
|
||||
function toPlainText(raw: string): string {
|
||||
return (
|
||||
raw
|
||||
// Strip special tags and their contents
|
||||
.replace(new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'), '')
|
||||
// Strip markdown
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/`{3}[\s\S]*?`{3}/g, '')
|
||||
.replace(/`(.+?)`/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/^[>\-*]\s+/gm, '')
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
|
||||
// Normalize whitespace
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
)
|
||||
}
|
||||
|
||||
const ICON_CLASS = 'size-[14px]'
|
||||
const BUTTON_CLASS =
|
||||
'flex size-[26px] items-center justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'
|
||||
|
||||
interface MessageActionsProps {
|
||||
content: string
|
||||
userQuery?: string
|
||||
requestId?: string
|
||||
messageId?: string
|
||||
}
|
||||
|
||||
export const MessageActions = memo(function MessageActions({
|
||||
content,
|
||||
userQuery,
|
||||
requestId,
|
||||
messageId,
|
||||
}: MessageActionsProps) {
|
||||
const router = useRouter()
|
||||
const params = useParams<{ workspaceId: string }>()
|
||||
const { chatId } = useChatSurface()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [copiedRequestId, setCopiedRequestId] = useState(false)
|
||||
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
|
||||
const [feedbackText, setFeedbackText] = useState('')
|
||||
const resetTimeoutRef = useRef<number | null>(null)
|
||||
const requestIdTimeoutRef = useRef<number | null>(null)
|
||||
const submitFeedback = useSubmitCopilotFeedback()
|
||||
const forkChat = useForkMothershipChat(params.workspaceId)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current)
|
||||
}
|
||||
if (requestIdTimeoutRef.current !== null) {
|
||||
window.clearTimeout(requestIdTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (!content) return
|
||||
const text = toPlainText(content)
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current)
|
||||
}
|
||||
resetTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
const copyRequestId = async () => {
|
||||
if (!requestId) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(requestId)
|
||||
setCopiedRequestId(true)
|
||||
if (requestIdTimeoutRef.current !== null) {
|
||||
window.clearTimeout(requestIdTimeoutRef.current)
|
||||
}
|
||||
requestIdTimeoutRef.current = window.setTimeout(() => setCopiedRequestId(false), 1500)
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
const handleFeedbackClick = (type: 'up' | 'down') => {
|
||||
if (chatId && userQuery) {
|
||||
setPendingFeedback(type)
|
||||
setFeedbackText('')
|
||||
setCopiedRequestId(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitFeedback = () => {
|
||||
if (!pendingFeedback || !chatId || !userQuery) return
|
||||
const text = feedbackText.trim()
|
||||
if (!text) {
|
||||
setPendingFeedback(null)
|
||||
setFeedbackText('')
|
||||
return
|
||||
}
|
||||
submitFeedback.mutate({
|
||||
chatId,
|
||||
userQuery,
|
||||
agentResponse: content,
|
||||
isPositiveFeedback: pendingFeedback === 'up',
|
||||
feedback: text,
|
||||
})
|
||||
setPendingFeedback(null)
|
||||
setFeedbackText('')
|
||||
}
|
||||
|
||||
const handleModalClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
setPendingFeedback(null)
|
||||
setFeedbackText('')
|
||||
setCopiedRequestId(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFork = async () => {
|
||||
if (!chatId || !messageId || forkChat.isPending) return
|
||||
try {
|
||||
const result = await forkChat.mutateAsync({ chatId, upToMessageId: messageId })
|
||||
useFolderStore.getState().clearChatSelection()
|
||||
router.push(`/workspace/${params.workspaceId}/chat/${result.id}`)
|
||||
} catch {
|
||||
toast.error('Failed to fork chat')
|
||||
}
|
||||
}
|
||||
|
||||
const hasContent = Boolean(content)
|
||||
const canSubmitFeedback = Boolean(chatId && userQuery)
|
||||
const canFork = false
|
||||
if (!hasContent && !canSubmitFeedback && !canFork) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center gap-0.5'>
|
||||
{hasContent && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Copy message'
|
||||
onClick={copyToClipboard}
|
||||
className={BUTTON_CLASS}
|
||||
>
|
||||
{copied ? <Check className={ICON_CLASS} /> : <Duplicate className={ICON_CLASS} />}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{copied ? 'Copied message' : 'Copy message'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{canSubmitFeedback && (
|
||||
<>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Like'
|
||||
onClick={() => handleFeedbackClick('up')}
|
||||
className={BUTTON_CLASS}
|
||||
>
|
||||
<ThumbsUp className={ICON_CLASS} />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>Good response</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Dislike'
|
||||
onClick={() => handleFeedbackClick('down')}
|
||||
className={BUTTON_CLASS}
|
||||
>
|
||||
<ThumbsDown className={ICON_CLASS} />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>Bad response</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</>
|
||||
)}
|
||||
{canFork && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Fork from here'
|
||||
onClick={handleFork}
|
||||
disabled={forkChat.isPending}
|
||||
className={cn(BUTTON_CLASS, forkChat.isPending && 'cursor-not-allowed opacity-50')}
|
||||
>
|
||||
<GitBranch className={ICON_CLASS} />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>Fork from here</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChipModal
|
||||
open={pendingFeedback !== null}
|
||||
onOpenChange={handleModalClose}
|
||||
srTitle='Give feedback'
|
||||
>
|
||||
<ChipModalHeader onClose={() => handleModalClose(false)}>Give feedback</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<div className='flex items-start justify-between gap-2 px-2'>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{pendingFeedback === 'up' ? 'What did you like?' : 'What could be improved?'}
|
||||
</p>
|
||||
{pendingFeedback === 'down' && requestId && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Copy request ID'
|
||||
onClick={copyRequestId}
|
||||
className='flex size-[22px] shrink-0 items-center justify-center rounded-full text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'
|
||||
>
|
||||
{copiedRequestId ? (
|
||||
<Check className='size-[14px]' />
|
||||
) : (
|
||||
<Duplicate className='size-[14px]' />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{copiedRequestId ? 'Copied request ID' : 'Copy request ID'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
<ChipModalField
|
||||
type='textarea'
|
||||
title='Feedback'
|
||||
value={feedbackText}
|
||||
onChange={setFeedbackText}
|
||||
rows={6}
|
||||
minHeight={140}
|
||||
resizable
|
||||
placeholder={
|
||||
pendingFeedback === 'up'
|
||||
? 'Tell us what was helpful...'
|
||||
: 'Tell us what went wrong...'
|
||||
}
|
||||
/>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => handleModalClose(false)}
|
||||
primaryAction={{
|
||||
label: 'Submit',
|
||||
onClick: handleSubmitFeedback,
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
</>
|
||||
)
|
||||
})
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { memo } from 'react'
|
||||
import { cn, FloatingTooltip, isTextClipped, useFloatingTooltip, useIsOverflowing } from '@sim/emcn'
|
||||
|
||||
interface FloatingOverflowTextProps {
|
||||
/** Full text shown in the tooltip and used as the default visible content. */
|
||||
label: string
|
||||
/** Optional custom visible content (e.g. highlighted text); defaults to `label`. */
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
/** Forces the tooltip even when the text is not visually clipped (e.g. content truncated upstream). */
|
||||
showWhen?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncating text that fades its clipped edge and reveals the full value in a
|
||||
* pointer-reactive floating tooltip on hover or focus.
|
||||
*/
|
||||
export const FloatingOverflowText = memo(function FloatingOverflowText({
|
||||
label,
|
||||
children,
|
||||
className,
|
||||
showWhen,
|
||||
}: FloatingOverflowTextProps) {
|
||||
const { ref: textRef, node, isOverflowing } = useIsOverflowing<HTMLSpanElement>()
|
||||
const { state, handlers } = useFloatingTooltip(() => {
|
||||
const element = node.current
|
||||
if (!element || label.length === 0) return false
|
||||
return Boolean(showWhen) || isTextClipped(element)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={textRef}
|
||||
className={cn(
|
||||
'min-w-0',
|
||||
isOverflowing &&
|
||||
'[mask-image:linear-gradient(to_right,black_calc(100%-18px),transparent)] hover:[mask-image:none] focus-visible:[mask-image:none]',
|
||||
className
|
||||
)}
|
||||
{...handlers}
|
||||
>
|
||||
{children ?? label}
|
||||
</span>
|
||||
<FloatingTooltip label={label} state={state} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ownerCell } from './owner-cell'
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { memo } from 'react'
|
||||
import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource'
|
||||
import type { WorkspaceMember } from '@/hooks/queries/workspace'
|
||||
|
||||
interface OwnerAvatarProps {
|
||||
name: string
|
||||
image: string | null
|
||||
}
|
||||
|
||||
const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) {
|
||||
if (image) {
|
||||
return (
|
||||
<img
|
||||
src={image}
|
||||
alt={name}
|
||||
referrerPolicy='no-referrer'
|
||||
className='size-[14px] rounded-full border border-[var(--border)] object-cover'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className='flex size-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolves a user ID into a ResourceCell with an avatar icon and display name.
|
||||
* Returns null label while members are still loading to avoid flashing raw IDs.
|
||||
*
|
||||
* Accepts either the raw member array or a precomputed `userId → member` map.
|
||||
* Prefer the map form when resolving many rows so lookups stay O(1) instead of
|
||||
* scanning the array per row.
|
||||
*/
|
||||
export function ownerCell(
|
||||
userId: string | null | undefined,
|
||||
members?: WorkspaceMember[] | Map<string, WorkspaceMember>
|
||||
): ResourceCell {
|
||||
if (!userId) return { label: null }
|
||||
if (!members) return { label: null }
|
||||
|
||||
const member =
|
||||
members instanceof Map ? members.get(userId) : members.find((m) => m.userId === userId)
|
||||
if (!member) return { label: null }
|
||||
|
||||
return {
|
||||
icon: <OwnerAvatar name={member.name} image={member.image} />,
|
||||
label: member.name,
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export type { ChromeActionSpec } from './resource-chrome-fallback'
|
||||
export { ResourceChromeFallback } from './resource-chrome-fallback'
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentType } from 'react'
|
||||
import { noop } from '@sim/utils/helpers'
|
||||
import type { BreadcrumbItem } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header'
|
||||
import {
|
||||
Resource,
|
||||
type ResourceColumn,
|
||||
} from '@/app/workspace/[workspaceId]/components/resource/resource'
|
||||
|
||||
/**
|
||||
* The static visual shape of a header action chip. The loading fallback only
|
||||
* needs the chrome (text/icon/variant/active) — handlers are no-ops during a
|
||||
* route transition — so the dynamic `onSelect`/`disabled` are intentionally
|
||||
* omitted. Mirrors {@link ResourceAction}'s chrome fields.
|
||||
*/
|
||||
export interface ChromeActionSpec {
|
||||
text: string
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
variant?: 'primary' | 'destructive'
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
interface ResourceChromeFallbackProps {
|
||||
/** Title-mode icon (list pages) or breadcrumb-root icon (detail pages). */
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
/** Title-mode label. Omit when `breadcrumbs` is supplied. */
|
||||
title?: string
|
||||
/**
|
||||
* Static breadcrumb trail for detail-route fallbacks. The live leaf name is
|
||||
* unknown at load, so pass the known root crumb(s) plus a terminal `…`
|
||||
* placeholder; it fills in once the page mounts.
|
||||
*/
|
||||
breadcrumbs?: BreadcrumbItem[]
|
||||
/** Table column headers. Omit on bodies that don't render `Resource.Table` (the table editor). */
|
||||
columns?: ResourceColumn[]
|
||||
/** The page's exact header action chips (handlers wired to a no-op). */
|
||||
actions?: ChromeActionSpec[]
|
||||
/** Search placeholder. Omit to hide the search box (matches a page with no search). */
|
||||
searchPlaceholder?: string
|
||||
/** Paint the Sort chip (its menu never opens during the fallback, so the option list is irrelevant). */
|
||||
hasSort?: boolean
|
||||
/** Paint the Filter chip. */
|
||||
hasFilter?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-transition fallback rendered by each resource route's `loading.tsx`. It
|
||||
* paints the REAL resource chrome — the header (icon/title or breadcrumbs + the
|
||||
* page's exact action chips), the options bar (search + the Filter/Sort chips),
|
||||
* and the table's column headers — with an empty body and no-op handlers, so a
|
||||
* navigation never shows a blank frame or a skeleton. Only the breadcrumb leaf
|
||||
* and the row data are unknown at load; everything else matches the loaded page.
|
||||
*/
|
||||
export function ResourceChromeFallback({
|
||||
icon,
|
||||
title,
|
||||
breadcrumbs,
|
||||
columns,
|
||||
actions,
|
||||
searchPlaceholder,
|
||||
hasSort = false,
|
||||
hasFilter = false,
|
||||
}: ResourceChromeFallbackProps) {
|
||||
return (
|
||||
<Resource>
|
||||
<Resource.Header
|
||||
icon={icon}
|
||||
title={title}
|
||||
breadcrumbs={breadcrumbs}
|
||||
actions={actions?.map((action) => ({
|
||||
text: action.text,
|
||||
icon: action.icon,
|
||||
variant: action.variant,
|
||||
active: action.active,
|
||||
onSelect: noop,
|
||||
}))}
|
||||
/>
|
||||
<Resource.Options
|
||||
search={
|
||||
searchPlaceholder !== undefined
|
||||
? { value: '', onChange: noop, placeholder: searchPlaceholder }
|
||||
: undefined
|
||||
}
|
||||
sort={hasSort ? { options: [], active: null, onSort: noop } : undefined}
|
||||
filter={hasFilter ? { content: null } : undefined}
|
||||
/>
|
||||
{columns ? <Resource.Table columns={columns} rows={[]} /> : null}
|
||||
</Resource>
|
||||
)
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
BreadcrumbEditing,
|
||||
BreadcrumbItem,
|
||||
DropdownOption,
|
||||
ResourceAction,
|
||||
} from './resource-header'
|
||||
export { ResourceHeader } from './resource-header'
|
||||
+657
@@ -0,0 +1,657 @@
|
||||
import {
|
||||
type ComponentType,
|
||||
Fragment,
|
||||
forwardRef,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
Chip,
|
||||
ChipChevronDown,
|
||||
chipContentIconClass,
|
||||
chipGeometryClass,
|
||||
chipVariants,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
FloatingTooltip,
|
||||
POPOVER_ANIMATION_CLASSES,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverSection,
|
||||
useFloatingTooltip,
|
||||
useIsOverflowing,
|
||||
} from '@sim/emcn'
|
||||
import { ArrowUpLeft } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
|
||||
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
|
||||
|
||||
export interface DropdownOption {
|
||||
label: string
|
||||
icon?: React.ElementType
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface BreadcrumbEditing {
|
||||
isEditing: boolean
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
onCancel: () => void
|
||||
/**
|
||||
* Disables the rename field while the save is in flight, mirroring
|
||||
* {@link ResourceCellEditing.disabled} on table cells. Threaded from
|
||||
* `useInlineRename`'s `isSaving`. Optional so existing consumers keep
|
||||
* working unchanged.
|
||||
*/
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string
|
||||
icon?: React.ElementType
|
||||
onClick?: () => void
|
||||
dropdownItems?: DropdownOption[]
|
||||
editing?: BreadcrumbEditing
|
||||
/**
|
||||
* Marks a non-navigable trailing crumb (e.g. "New Chunk", "Loading...") so the
|
||||
* header sizes it as the terminal segment rather than the current resource.
|
||||
*/
|
||||
terminal?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The single, strict contract for a top-right header action. Every action renders
|
||||
* as a {@link Chip} — consumers describe intent through these fields and nothing
|
||||
* else, so the action row looks identical on every page and cannot drift. Omit
|
||||
* `variant` for the default chip; use `primary`/`destructive` for emphasis. Express
|
||||
* a selected/toggle state with `active` (e.g. the Logs/Dashboard view toggle).
|
||||
*/
|
||||
export interface ResourceAction {
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
text: string
|
||||
variant?: 'primary' | 'destructive'
|
||||
active?: boolean
|
||||
onSelect: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface ResourceHeaderProps {
|
||||
icon?: React.ElementType
|
||||
title?: string
|
||||
breadcrumbs?: BreadcrumbItem[]
|
||||
/** Strict top-right action chips. List pages use ONLY this. */
|
||||
actions?: ResourceAction[]
|
||||
/**
|
||||
* Supplementary right-aligned content rendered before `actions` — custom
|
||||
* widgets that cannot collapse into the strict {@link ResourceAction} chip
|
||||
* contract, e.g. the table editor's run/stop control, an import-progress
|
||||
* menu, or a create dropdown. Anything that fits the chip contract belongs
|
||||
* in `actions`; never stuff primary actions in here.
|
||||
*/
|
||||
aside?: ReactNode
|
||||
}
|
||||
|
||||
export const ResourceHeader = memo(function ResourceHeader({
|
||||
icon: Icon,
|
||||
title,
|
||||
breadcrumbs,
|
||||
actions,
|
||||
aside,
|
||||
}: ResourceHeaderProps) {
|
||||
const headerRef = useRef<HTMLDivElement>(null)
|
||||
/**
|
||||
* Breadcrumb mode is reserved for nested pages (length > 1). A single-crumb
|
||||
* "breadcrumb" is just the current page, so it falls through to the static
|
||||
* title below — keeping the top-left non-interactive and hover-free,
|
||||
* identical to a title-only page (e.g. the Files root matches the Tables root).
|
||||
*/
|
||||
const hasBreadcrumbs = breadcrumbs != null && breadcrumbs.length > 1
|
||||
const rootCrumb = breadcrumbs?.length === 1 ? breadcrumbs[0] : undefined
|
||||
const TitleIcon = Icon ?? rootCrumb?.icon
|
||||
const titleLabel = title ?? rootCrumb?.label
|
||||
const terminalBreadcrumbIndex =
|
||||
hasBreadcrumbs && breadcrumbs[breadcrumbs.length - 1].terminal ? breadcrumbs.length - 1 : -1
|
||||
const currentResourceIndex =
|
||||
terminalBreadcrumbIndex > -1
|
||||
? terminalBreadcrumbIndex - 1
|
||||
: hasBreadcrumbs && breadcrumbs.length > 2
|
||||
? breadcrumbs.length - 1
|
||||
: -1
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={headerRef}
|
||||
className='flex min-h-[48px] items-center border-[var(--border)] border-b px-4 py-[8.5px]'
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center justify-between gap-3'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
|
||||
{hasBreadcrumbs ? (
|
||||
breadcrumbs.map((crumb, i) => {
|
||||
const segmentClassName = getBreadcrumbSegmentClassName(
|
||||
i,
|
||||
breadcrumbs.length,
|
||||
currentResourceIndex,
|
||||
terminalBreadcrumbIndex
|
||||
)
|
||||
const LocationIcon = i === 0 ? (crumb.icon ?? Icon) : undefined
|
||||
/**
|
||||
* The first crumb on a nested page opens the hover "path" popover
|
||||
* (back-navigation). Single-crumb roots never reach here — they
|
||||
* render as the static title above.
|
||||
*/
|
||||
const showLocationPopover = LocationIcon != null
|
||||
|
||||
return (
|
||||
<Fragment key={`${crumb.label}-${i}`}>
|
||||
{i > 0 && (
|
||||
<span className='mx-0.5 shrink-0 select-none text-[var(--text-icon)] text-sm'>
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
{showLocationPopover ? (
|
||||
<BreadcrumbLocationPopover
|
||||
icon={LocationIcon}
|
||||
breadcrumbs={breadcrumbs}
|
||||
className={segmentClassName}
|
||||
veilBoundaryRef={headerRef}
|
||||
/>
|
||||
) : (
|
||||
<BreadcrumbSegment
|
||||
icon={LocationIcon ?? crumb.icon}
|
||||
label={crumb.label}
|
||||
onClick={crumb.onClick}
|
||||
dropdownItems={crumb.dropdownItems}
|
||||
editing={crumb.editing}
|
||||
className={segmentClassName}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
/**
|
||||
* Root titles are short static labels ("Tables", "Files"), so the
|
||||
* span is non-shrinkable and the label never truncates — matching
|
||||
* the `shrink-0` guarantee the breadcrumb root crumb gets from
|
||||
* {@link getBreadcrumbSegmentClassName}. Without this, the
|
||||
* `flex-1` left column collapses during transient initial-load
|
||||
* layout (the JS-driven `--sidebar-width` settling) and the title
|
||||
* CSS-truncates to "T…" while the `shrink-0` actions hold width.
|
||||
*/
|
||||
<span className={cn(chipGeometryClass, 'inline-flex shrink-0 cursor-default')}>
|
||||
{TitleIcon && <TitleIcon className={chipContentIconClass} />}
|
||||
{titleLabel && (
|
||||
<FloatingOverflowText
|
||||
label={titleLabel}
|
||||
className='block whitespace-nowrap text-[var(--text-body)] text-sm'
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(aside || (actions && actions.length > 0)) && (
|
||||
<div className='flex shrink-0 items-center'>
|
||||
{aside}
|
||||
{actions?.map((action) => (
|
||||
<Chip
|
||||
key={action.text}
|
||||
variant={action.variant}
|
||||
active={action.active}
|
||||
leftIcon={action.icon}
|
||||
onClick={action.onSelect}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
{action.text}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function getBreadcrumbSegmentClassName(
|
||||
index: number,
|
||||
total: number,
|
||||
currentResourceIndex: number,
|
||||
terminalBreadcrumbIndex: number
|
||||
): string {
|
||||
if (index === terminalBreadcrumbIndex) {
|
||||
return 'shrink-0 max-w-[10rem]'
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
return 'shrink-0'
|
||||
}
|
||||
|
||||
if (currentResourceIndex > -1) {
|
||||
if (index === currentResourceIndex) {
|
||||
return 'min-w-0 flex-[0_1_auto] max-w-[56%]'
|
||||
}
|
||||
|
||||
return 'min-w-0 flex-[0_1_auto] max-w-[34%]'
|
||||
}
|
||||
|
||||
if (total > 2) {
|
||||
return 'min-w-0 flex-[0_1_auto] max-w-[42%]'
|
||||
}
|
||||
|
||||
return 'min-w-0 flex-[0_1_auto] max-w-[min(32rem,55vw)]'
|
||||
}
|
||||
|
||||
interface BreadcrumbSegmentProps {
|
||||
icon?: React.ElementType
|
||||
label: string
|
||||
onClick?: () => void
|
||||
dropdownItems?: DropdownOption[]
|
||||
editing?: BreadcrumbEditing
|
||||
className?: string
|
||||
}
|
||||
|
||||
const BreadcrumbSegment = memo(function BreadcrumbSegment({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
dropdownItems,
|
||||
editing,
|
||||
className,
|
||||
}: BreadcrumbSegmentProps) {
|
||||
const { ref: labelRef, node: labelNode, isOverflowing } = useIsOverflowing<HTMLSpanElement>()
|
||||
const { state: tooltipState, handlers: tooltipHandlers } = useFloatingTooltip((target) =>
|
||||
isBreadcrumbTextClipped(labelNode.current, target)
|
||||
)
|
||||
|
||||
if (editing?.isEditing) {
|
||||
return (
|
||||
<span className={cn(chipGeometryClass, 'inline-flex min-w-0 justify-start', className)}>
|
||||
{Icon && <Icon className={chipContentIconClass} />}
|
||||
<InlineRenameInput
|
||||
value={editing.value}
|
||||
onChange={editing.onChange}
|
||||
onSubmit={editing.onSubmit}
|
||||
onCancel={editing.onCancel}
|
||||
disabled={editing.disabled}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{Icon && <Icon className={chipContentIconClass} />}
|
||||
<BreadcrumbLabel ref={labelRef} isOverflowing={isOverflowing} label={label} />
|
||||
</>
|
||||
)
|
||||
/**
|
||||
* Interactive crumbs use a plain `<button>` with bare-chip geometry — NEVER
|
||||
* the Button component, whose buttonVariants inject font-medium /
|
||||
* rounded-[5px] / justify-center and break chip parity with the static/title
|
||||
* crumbs.
|
||||
*/
|
||||
const triggerClassName = cn(
|
||||
chipVariants({ flush: true }),
|
||||
'group min-w-0 max-w-full justify-start'
|
||||
)
|
||||
|
||||
if (dropdownItems && dropdownItems.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<FloatingTooltip label={label} state={tooltipState} />
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type='button' className={cn(triggerClassName, className)} {...tooltipHandlers}>
|
||||
{content}
|
||||
<ChipChevronDown className='ml-auto' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
{dropdownItems.map((item) => {
|
||||
const ItemIcon = item.icon
|
||||
return (
|
||||
<DropdownMenuItem key={item.label} onClick={item.onClick} disabled={item.disabled}>
|
||||
{ItemIcon && <ItemIcon className='size-[14px]' />}
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<>
|
||||
<FloatingTooltip label={label} state={tooltipState} />
|
||||
<button
|
||||
type='button'
|
||||
className={cn(triggerClassName, className)}
|
||||
onClick={onClick}
|
||||
{...tooltipHandlers}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingTooltip label={label} state={tooltipState} />
|
||||
<span
|
||||
className={cn(
|
||||
chipGeometryClass,
|
||||
'group inline-flex min-w-0 max-w-full cursor-default justify-start',
|
||||
className
|
||||
)}
|
||||
{...tooltipHandlers}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
interface BreadcrumbLocationPopoverProps {
|
||||
icon: React.ElementType
|
||||
breadcrumbs: BreadcrumbItem[]
|
||||
className?: string
|
||||
veilBoundaryRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Grace period before a hover-out dismisses the path popover. Covers the gap
|
||||
* the pointer crosses between the trigger and the popover content (and brief
|
||||
* jitter at their edges); re-entering either within this window cancels the
|
||||
* close. Standard hover-intent close delay — not tied to any navigation timing.
|
||||
*/
|
||||
const POPOVER_CLOSE_DELAY_MS = 120
|
||||
|
||||
function BreadcrumbLocationPopover({
|
||||
icon: Icon,
|
||||
breadcrumbs,
|
||||
className,
|
||||
veilBoundaryRef,
|
||||
}: BreadcrumbLocationPopoverProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const rootBreadcrumb = breadcrumbs[0]
|
||||
|
||||
const cancelScheduledClose = () => {
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current)
|
||||
closeTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover-intent open. Driven only by pointer-/keyboard-enter — never by
|
||||
* pointer movement. This is what makes the popover dismiss cleanly on a
|
||||
* click-to-navigate: a stationary click fires no enter event, so once
|
||||
* {@link navigateAndClose} sets `open` false nothing re-opens it before the
|
||||
* route swaps. (A move-driven open would re-fire under the resting cursor and
|
||||
* flash the popover/veil back in mid-navigation.)
|
||||
*/
|
||||
const openPopover = () => {
|
||||
cancelScheduledClose()
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
cancelScheduledClose()
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
setOpen(false)
|
||||
closeTimeoutRef.current = null
|
||||
}, POPOVER_CLOSE_DELAY_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the popover up front, then runs the crumb's handler. Closing first
|
||||
* lets the veil fade and the popover play its exit animation instead of
|
||||
* snapping away when navigation unmounts the header.
|
||||
*/
|
||||
const navigateAndClose = (onClick?: () => void) => {
|
||||
if (!onClick) return
|
||||
cancelScheduledClose()
|
||||
setOpen(false)
|
||||
onClick()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<LocationFocusVeil visible={open} boundaryRef={veilBoundaryRef} />
|
||||
<Popover size='md' open={open} onOpenChange={setOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label={rootBreadcrumb?.label ?? 'Path'}
|
||||
onClick={() => navigateAndClose(rootBreadcrumb?.onClick)}
|
||||
onFocus={openPopover}
|
||||
onBlur={scheduleClose}
|
||||
onMouseEnter={openPopover}
|
||||
onMouseLeave={scheduleClose}
|
||||
className={cn(
|
||||
chipVariants({ flush: true }),
|
||||
'max-w-none gap-1.5 px-2 transition-colors',
|
||||
open && 'relative z-[var(--z-popover)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className='relative inline-grid size-[16px] shrink-0 place-items-center'>
|
||||
<Icon className='col-start-1 row-start-1 size-[16px] text-[var(--text-icon)] opacity-100 blur-0 transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-[0.25] group-hover:opacity-0 group-hover:blur-[2px] group-focus-visible:scale-[0.25] group-focus-visible:opacity-0 group-focus-visible:blur-[2px] motion-reduce:transition-none' />
|
||||
<ArrowUpLeft
|
||||
strokeWidth={1.55}
|
||||
className='col-start-1 row-start-1 size-[16px] scale-[0.25] text-[var(--text-icon)] opacity-0 blur-[2px] transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-100 group-hover:opacity-100 group-hover:blur-0 group-focus-visible:scale-100 group-focus-visible:opacity-100 group-focus-visible:blur-0 motion-reduce:transition-none'
|
||||
/>
|
||||
</span>
|
||||
{rootBreadcrumb?.label && (
|
||||
<span className='shrink-0 truncate text-[var(--text-body)] text-sm'>
|
||||
{rootBreadcrumb.label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='start'
|
||||
sideOffset={6}
|
||||
minWidth={220}
|
||||
maxWidth={300}
|
||||
maxHeight={420}
|
||||
border
|
||||
className={cn(
|
||||
POPOVER_ANIMATION_CLASSES,
|
||||
'bg-[var(--bg)] p-1.5 text-[var(--text-body)] shadow-sm'
|
||||
)}
|
||||
onMouseEnter={openPopover}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<PopoverSection className='px-1.5 py-0.5 text-[var(--text-muted)] text-xs'>
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
<span>Path</span>
|
||||
<span className='opacity-70'>/</span>
|
||||
</span>
|
||||
</PopoverSection>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<BreadcrumbLocationItem
|
||||
key={`${crumb.label}-${index}`}
|
||||
icon={crumb.icon || (index === 0 ? Icon : undefined)}
|
||||
label={crumb.label}
|
||||
onClick={crumb.onClick ? () => navigateAndClose(crumb.onClick) : undefined}
|
||||
active={index === breadcrumbs.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LocationFocusVeil({
|
||||
visible,
|
||||
boundaryRef,
|
||||
}: {
|
||||
visible: boolean
|
||||
boundaryRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const [bounds, setBounds] = useState({ top: 0, left: 0 })
|
||||
/**
|
||||
* Portal-mount gate. The veil must render `null` on BOTH the server render
|
||||
* and the first client (hydration) render — branching on
|
||||
* `typeof document === 'undefined'` made the two renders diverge, which
|
||||
* failed hydration and forced React to regenerate the whole page tree on
|
||||
* the client (a visible header flash during load).
|
||||
*/
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
|
||||
const updateBounds = () => {
|
||||
const boundary = boundaryRef.current
|
||||
if (!boundary) return
|
||||
|
||||
const rect = boundary.getBoundingClientRect()
|
||||
setBounds({ top: rect.top, left: rect.left })
|
||||
}
|
||||
|
||||
updateBounds()
|
||||
window.addEventListener('resize', updateBounds)
|
||||
window.addEventListener('scroll', updateBounds, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateBounds)
|
||||
window.removeEventListener('scroll', updateBounds, true)
|
||||
}
|
||||
}, [boundaryRef, visible])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'pointer-events-none fixed right-0 bottom-0 z-[calc(var(--z-popover)-1)] bg-[var(--bg)] transition-opacity duration-150 ease-out motion-reduce:transition-none',
|
||||
visible ? 'opacity-60' : 'opacity-0'
|
||||
)}
|
||||
style={{ top: bounds.top, left: bounds.left }}
|
||||
/>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
interface BreadcrumbLocationItemProps {
|
||||
icon?: React.ElementType
|
||||
label: string
|
||||
onClick?: () => void
|
||||
active: boolean
|
||||
}
|
||||
|
||||
function BreadcrumbLocationItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
active,
|
||||
}: BreadcrumbLocationItemProps) {
|
||||
const labelContent = (
|
||||
<>
|
||||
<span className='flex size-[18px] shrink-0 items-center justify-center'>
|
||||
{Icon ? (
|
||||
<Icon className='size-3 text-[var(--text-icon)]' />
|
||||
) : (
|
||||
<span className='size-1.5 rounded-full bg-[var(--text-muted)]' />
|
||||
)}
|
||||
</span>
|
||||
<span className='min-w-0 flex-1 truncate text-left'>{label}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<PopoverItem
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
className='h-7 items-center gap-1.5 px-1.5 py-0 text-xs'
|
||||
>
|
||||
{labelContent}
|
||||
</PopoverItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-7 min-w-0 items-center gap-1.5 rounded-lg px-1.5 text-[var(--text-body)] text-xs',
|
||||
active && 'bg-[var(--surface-active)]'
|
||||
)}
|
||||
>
|
||||
{labelContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const BreadcrumbLabel = memo(
|
||||
forwardRef<HTMLSpanElement, BreadcrumbLabelProps>(function BreadcrumbLabel(
|
||||
{ isOverflowing, label },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'min-w-0 truncate text-[var(--text-body)]',
|
||||
isOverflowing &&
|
||||
'[mask-image:linear-gradient(to_right,black_calc(100%-18px),transparent)] group-hover:[mask-image:none] group-focus-visible:[mask-image:none]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
interface BreadcrumbLabelProps {
|
||||
isOverflowing: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
function isBreadcrumbTextClipped(
|
||||
labelElement: HTMLSpanElement | null,
|
||||
triggerElement: HTMLElement
|
||||
): boolean {
|
||||
if (!labelElement) return false
|
||||
|
||||
const labelWidth = labelElement.getBoundingClientRect().width
|
||||
const triggerWidth = triggerElement.getBoundingClientRect().width
|
||||
const visibleLabelWidth = Math.min(labelWidth, triggerWidth)
|
||||
|
||||
return (
|
||||
labelElement.scrollWidth > labelElement.clientWidth + 1 ||
|
||||
triggerElement.scrollWidth > triggerElement.clientWidth + 1 ||
|
||||
labelElement.scrollWidth > visibleLabelWidth + 1
|
||||
)
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
ColumnOption,
|
||||
FilterConfig,
|
||||
FilterTag,
|
||||
SearchConfig,
|
||||
SearchTag,
|
||||
SortConfig,
|
||||
} from './resource-options'
|
||||
export { ResourceOptions, SortDropdown } from './resource-options'
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
import { memo, type ReactNode, useState } from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Chip,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
ListFilter,
|
||||
POPOVER_ANIMATION_CLASSES,
|
||||
Search,
|
||||
X,
|
||||
} from '@sim/emcn'
|
||||
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
|
||||
|
||||
const SEARCH_ICON = (
|
||||
<Search className='pointer-events-none size-[14px] shrink-0 text-[var(--text-muted)]' />
|
||||
)
|
||||
|
||||
const RESOURCE_MENU_EDGE_OFFSET = 6
|
||||
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export interface ColumnOption {
|
||||
id: string
|
||||
label: string
|
||||
type?: string
|
||||
icon?: React.ElementType
|
||||
}
|
||||
|
||||
export interface SortConfig {
|
||||
options: ColumnOption[]
|
||||
active: { column: string; direction: SortDirection } | null
|
||||
onSort: (column: string, direction: SortDirection) => void
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
export interface FilterTag {
|
||||
label: string
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
export interface SearchTag {
|
||||
label: string
|
||||
value: string
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
export interface SearchConfig {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
onFocus?: () => void
|
||||
onBlur?: () => void
|
||||
tags?: SearchTag[]
|
||||
highlightedTagIndex?: number | null
|
||||
onClearAll?: () => void
|
||||
dropdown?: ReactNode
|
||||
dropdownRef?: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* The Filter control has two shapes, picked by `mode`:
|
||||
* - `popover` (default): list pages pass the filter controls as `content`; the
|
||||
* button opens them in a popover. `active` highlights the button.
|
||||
* - `toggle`: detail views (e.g. the table editor) that render their filter as a
|
||||
* separate panel toggle the button instead of opening a popover.
|
||||
*/
|
||||
export type FilterConfig =
|
||||
| { mode?: 'popover'; content: ReactNode; active?: boolean }
|
||||
| { mode: 'toggle'; active: boolean; onToggle: () => void }
|
||||
|
||||
interface ResourceOptionsProps {
|
||||
search?: SearchConfig
|
||||
sort?: SortConfig
|
||||
filter?: FilterConfig
|
||||
filterTags?: FilterTag[]
|
||||
/**
|
||||
* Lightweight control rendered immediately to the LEFT of the filter/sort
|
||||
* cluster, forming one group with it — e.g. the knowledge view's
|
||||
* connected-source badge or the table editor's embedded run/stop control. With
|
||||
* a search the group is pushed right (opposite the search); without one it
|
||||
* stays left-aligned (the embedded table editor). Keep it to badges/status
|
||||
* widgets; primary actions belong in the header's `actions`.
|
||||
*/
|
||||
aside?: ReactNode
|
||||
}
|
||||
|
||||
export const ResourceOptions = memo(function ResourceOptions({
|
||||
search,
|
||||
sort,
|
||||
filter,
|
||||
filterTags,
|
||||
aside,
|
||||
}: ResourceOptionsProps) {
|
||||
/**
|
||||
* Coordinates the Filter popover and Sort menu as a single menu bar: clicking
|
||||
* one while the other is open switches to it in a single click. Functional
|
||||
* updates make the close→open ordering race-proof, so whichever menu the click
|
||||
* targets wins regardless of which `onOpenChange` fires first.
|
||||
*/
|
||||
const [openMenu, setOpenMenu] = useState<'filter' | 'sort' | null>(null)
|
||||
|
||||
const isToggleFilter = filter?.mode === 'toggle'
|
||||
const popoverFilter = filter && filter.mode !== 'toggle' ? filter : null
|
||||
|
||||
const hasContent = search || sort || filter || aside || (filterTags && filterTags.length > 0)
|
||||
if (!hasContent) return null
|
||||
|
||||
return (
|
||||
<div className={cn('border-[var(--border)] border-b py-2.5', search ? 'px-6' : 'px-4')}>
|
||||
<div className='flex items-center'>
|
||||
{search && <SearchSection search={search} />}
|
||||
<div className={cn('flex shrink-0 items-center gap-1.5', search && 'ml-auto')}>
|
||||
{aside}
|
||||
<div className='flex items-center'>
|
||||
{filterTags?.map((tag) => (
|
||||
<Chip key={tag.label} rightIcon={X} onClick={tag.onRemove}>
|
||||
{tag.label}
|
||||
</Chip>
|
||||
))}
|
||||
{isToggleFilter && filter.mode === 'toggle' ? (
|
||||
<Chip active={filter.active} leftIcon={ListFilter} onClick={filter.onToggle}>
|
||||
Filter
|
||||
</Chip>
|
||||
) : popoverFilter ? (
|
||||
<PopoverPrimitive.Root
|
||||
open={openMenu === 'filter'}
|
||||
onOpenChange={(open) =>
|
||||
setOpenMenu((current) =>
|
||||
open ? 'filter' : current === 'filter' ? null : current
|
||||
)
|
||||
}
|
||||
>
|
||||
<PopoverPrimitive.Anchor asChild>
|
||||
<div className='flex items-center'>
|
||||
<PopoverPrimitive.Trigger asChild>
|
||||
<Chip active={popoverFilter.active} leftIcon={ListFilter}>
|
||||
Filter
|
||||
</Chip>
|
||||
</PopoverPrimitive.Trigger>
|
||||
{sort && (
|
||||
<SortDropdown
|
||||
config={sort}
|
||||
open={openMenu === 'sort'}
|
||||
onOpenChange={(open) =>
|
||||
setOpenMenu((current) =>
|
||||
open ? 'sort' : current === 'sort' ? null : current
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverPrimitive.Anchor>
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
align='end'
|
||||
alignOffset={RESOURCE_MENU_EDGE_OFFSET}
|
||||
collisionPadding={6}
|
||||
sideOffset={6}
|
||||
className={cn(
|
||||
POPOVER_ANIMATION_CLASSES,
|
||||
'z-50 w-fit origin-[--radix-popover-content-transform-origin] rounded-xl border border-[var(--border)] bg-[var(--bg)] shadow-sm'
|
||||
)}
|
||||
>
|
||||
{popoverFilter.content}
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPrimitive.Root>
|
||||
) : null}
|
||||
{sort && (isToggleFilter || !popoverFilter) && <SortDropdown config={sort} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const SearchSection = memo(function SearchSection({ search }: { search: SearchConfig }) {
|
||||
return (
|
||||
<div className='relative flex flex-1 items-center gap-1.5'>
|
||||
{SEARCH_ICON}
|
||||
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||
{search.tags?.map((tag, i) => (
|
||||
<Chip
|
||||
key={`${tag.label}-${tag.value}`}
|
||||
rightIcon={X}
|
||||
onClick={tag.onRemove}
|
||||
active={search.highlightedTagIndex === i}
|
||||
className='max-w-[280px] shrink-0'
|
||||
>
|
||||
<FloatingOverflowText label={`${tag.label}: ${tag.value}`} className='block truncate'>
|
||||
{tag.label}: {tag.value}
|
||||
</FloatingOverflowText>
|
||||
</Chip>
|
||||
))}
|
||||
<input
|
||||
ref={search.inputRef}
|
||||
type='text'
|
||||
value={search.value}
|
||||
onChange={(e) => search.onChange(e.target.value)}
|
||||
onKeyDown={search.onKeyDown}
|
||||
onFocus={search.onFocus}
|
||||
onBlur={search.onBlur}
|
||||
placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')}
|
||||
className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)]'
|
||||
/>
|
||||
</div>
|
||||
{search.tags?.length || search.value ? (
|
||||
<button
|
||||
type='button'
|
||||
className='mr-0.5 flex size-[14px] shrink-0 items-center justify-center text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-body)]'
|
||||
onClick={search.onClearAll ?? (() => search.onChange(''))}
|
||||
>
|
||||
<span className='text-caption'>✕</span>
|
||||
</button>
|
||||
) : null}
|
||||
{search.dropdown && (
|
||||
<div
|
||||
ref={search.dropdownRef}
|
||||
className='absolute top-full left-0 z-50 mt-1.5 w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
|
||||
>
|
||||
{search.dropdown}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
interface SortDropdownProps {
|
||||
config: SortConfig
|
||||
/** Controlled open state — omit for standalone (uncontrolled) usage. */
|
||||
open?: boolean
|
||||
/** Controlled open-change handler, paired with {@link SortDropdownProps.open}. */
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const SortDropdown = memo(function SortDropdown({
|
||||
config,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: SortDropdownProps) {
|
||||
const { options, active, onSort, onClear } = config
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Chip active={Boolean(active)} leftIcon={ArrowUpDown}>
|
||||
Sort
|
||||
</Chip>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
alignOffset={RESOURCE_MENU_EDGE_OFFSET}
|
||||
className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]'
|
||||
>
|
||||
{active && onClear && (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={onClear}>
|
||||
<X />
|
||||
Clear sort
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{options.map((option) => {
|
||||
const isActive = active?.column === option.id
|
||||
const Icon = option.icon
|
||||
const DirectionIcon = isActive ? (active.direction === 'asc' ? ArrowUp : ArrowDown) : null
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onSelect={() => {
|
||||
if (isActive) {
|
||||
onSort(option.id, active.direction === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
onSort(option.id, 'desc')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon />}
|
||||
{option.label}
|
||||
{DirectionIcon && (
|
||||
<DirectionIcon className='ml-auto size-[12px] text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource'
|
||||
|
||||
const SECOND = 1000
|
||||
const MINUTE = 60 * SECOND
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
const ORDINAL_RULES: [number, string][] = [
|
||||
[1, 'st'],
|
||||
[2, 'nd'],
|
||||
[3, 'rd'],
|
||||
]
|
||||
|
||||
function ordinalSuffix(day: number): string {
|
||||
if (day >= 11 && day <= 13) return 'th'
|
||||
return ORDINAL_RULES.find(([d]) => day % 10 === d)?.[1] ?? 'th'
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
] as const
|
||||
|
||||
function formatFullDate(date: Date): string {
|
||||
const month = MONTH_NAMES[date.getMonth()]
|
||||
const day = date.getDate()
|
||||
const year = date.getFullYear()
|
||||
return `${month} ${day}${ordinalSuffix(day)}, ${year}`
|
||||
}
|
||||
|
||||
function pluralize(value: number, unit: string): string {
|
||||
return `${value} ${unit}${value === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string into a human-friendly relative time label.
|
||||
*
|
||||
* - Within ~1 minute of now: "Now"
|
||||
* - Under 1 hour: "X minute(s) ago" / "X minute(s)"
|
||||
* - Under 24 hours: "X hour(s) ago" / "X hour(s)"
|
||||
* - Under 2 days: "X day(s) ago" / "X day(s)"
|
||||
* - Beyond 2 days: full date (e.g. "March 4th, 2026")
|
||||
*/
|
||||
export function timeCell(dateValue: string | Date | null | undefined): ResourceCell {
|
||||
if (!dateValue) return { label: null }
|
||||
|
||||
const date = dateValue instanceof Date ? dateValue : new Date(dateValue)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const absDiff = Math.abs(diff)
|
||||
const isPast = diff > 0
|
||||
|
||||
if (absDiff < MINUTE) return { label: 'Now' }
|
||||
|
||||
if (absDiff < HOUR) {
|
||||
const minutes = Math.floor(absDiff / MINUTE)
|
||||
return { label: isPast ? `${pluralize(minutes, 'minute')} ago` : pluralize(minutes, 'minute') }
|
||||
}
|
||||
|
||||
if (absDiff < DAY) {
|
||||
const hours = Math.floor(absDiff / HOUR)
|
||||
return { label: isPast ? `${pluralize(hours, 'hour')} ago` : pluralize(hours, 'hour') }
|
||||
}
|
||||
|
||||
if (absDiff < 2 * DAY) {
|
||||
const days = Math.floor(absDiff / DAY)
|
||||
return { label: isPast ? `${pluralize(days, 'day')} ago` : pluralize(days, 'day') }
|
||||
}
|
||||
|
||||
return { label: formatFullDate(date) }
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
'use client'
|
||||
import {
|
||||
type CSSProperties,
|
||||
type DragEvent,
|
||||
memo,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
cellIconNodeClass,
|
||||
chipContentGap,
|
||||
chipContentLabelClass,
|
||||
cn,
|
||||
Loader,
|
||||
} from '@sim/emcn'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
|
||||
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
|
||||
import { ResourceHeader } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header'
|
||||
import { ResourceOptions } from '@/app/workspace/[workspaceId]/components/resource/components/resource-options'
|
||||
|
||||
export interface ResourceColumn {
|
||||
id: string
|
||||
header: string
|
||||
widthMultiplier?: number
|
||||
}
|
||||
|
||||
export interface ResourceCellEditing {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
onCancel: () => void
|
||||
/**
|
||||
* Disables the rename field while the save is in flight, mirroring the
|
||||
* sidebar's `disabled={isRenaming}`. Threaded from `useInlineRename`'s
|
||||
* `isSaving`. Optional so existing consumers keep working unchanged.
|
||||
*/
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface ResourceCell {
|
||||
icon?: ReactNode
|
||||
label?: string | null
|
||||
content?: ReactNode
|
||||
/**
|
||||
* When set, the cell renders an inline rename field inside the canonical cell
|
||||
* chrome (icon + {@link InlineRenameInput}). Consumers pass structured handlers
|
||||
* instead of hand-rolling a `content` node, so every rename cell matches the
|
||||
* resting cell exactly (same gap, weight, icon size).
|
||||
*/
|
||||
editing?: ResourceCellEditing
|
||||
}
|
||||
|
||||
export interface ResourceRow {
|
||||
id: string
|
||||
cells: Record<string, ResourceCell>
|
||||
}
|
||||
|
||||
export interface SelectableConfig {
|
||||
selectedIds: Set<string>
|
||||
onSelectRow: (id: string, checked: boolean, shiftKey?: boolean) => void
|
||||
onSelectAll: (checked: boolean) => void
|
||||
isAllSelected: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface RowDragDropConfig {
|
||||
activeDropTargetId?: string | null
|
||||
draggedRowIds?: Set<string>
|
||||
isAnyDragActive?: boolean
|
||||
isRowDraggable?: (rowId: string) => boolean
|
||||
isRowDropTarget?: (rowId: string) => boolean
|
||||
onDragStart?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
|
||||
onDragOver?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
|
||||
onDragLeave?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
|
||||
onDrop?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
|
||||
onDragEnd?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
|
||||
}
|
||||
|
||||
export interface PaginationConfig {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
export const EMPTY_CELL_PLACEHOLDER = '—'
|
||||
|
||||
/**
|
||||
* Seed height (px) for each virtualized row before it is measured. Every
|
||||
* consumer renders single-line `py-2.5` cells, so this matches the resting row
|
||||
* height closely; `measureElement` then corrects each row to its exact pixel
|
||||
* height after mount, so the estimate only affects pre-measure scroll math.
|
||||
*/
|
||||
const ROW_HEIGHT_ESTIMATE = 41 as const
|
||||
|
||||
/** Rows rendered above/below the viewport to avoid blank flashes on fast scroll. */
|
||||
const ROW_OVERSCAN = 8 as const
|
||||
|
||||
const CHECKBOX_COLUMN_WIDTH = '52px'
|
||||
|
||||
/**
|
||||
* Builds the shared CSS grid track list for the header and every body row from
|
||||
* the same first-column-weighted ratios the legacy `<colgroup>` used, so the
|
||||
* virtualized grid layout reproduces the exact column widths. The checkbox
|
||||
* column, when present, is a fixed leading track.
|
||||
*/
|
||||
function buildGridTemplateColumns(columns: ResourceColumn[], hasCheckbox: boolean): string {
|
||||
const weights = columns.map(
|
||||
(col, colIdx) => (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1)
|
||||
)
|
||||
const total = weights.reduce((s, w) => s + w, 0)
|
||||
const tracks = columns.map((_, colIdx) => `minmax(0, ${(weights[colIdx] / total).toFixed(6)}fr)`)
|
||||
return hasCheckbox ? `${CHECKBOX_COLUMN_WIDTH} ${tracks.join(' ')}` : tracks.join(' ')
|
||||
}
|
||||
|
||||
interface ResourceProps {
|
||||
children: ReactNode
|
||||
onContextMenu?: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Compound page shell for resource pages (tables, files, knowledge, schedules,
|
||||
* logs, and the detail editors). Consumers import only `Resource` and fill the
|
||||
* defined slots as children:
|
||||
*
|
||||
* - `Resource.Header` — required, the top bar (title/breadcrumbs + action chips)
|
||||
* - `Resource.Options` — required, the search/filter/sort toolbar
|
||||
* - `Resource.Table` — optional; swap for any custom body (dashboard, grid, …)
|
||||
*
|
||||
* Invariant: the shell renders identically for every consumer. Consumers supply
|
||||
* content (columns, rows, cells) and behavior (handlers, configs) only — no
|
||||
* prop changes the shell's chrome, spacing, or structure. The only sanctioned
|
||||
* variation is replacing `Resource.Table` with a custom body.
|
||||
*
|
||||
* The shell owns the fixed column layout and is the positioning context for
|
||||
* absolutely-positioned overlays (action bars, slide-out sidebars); the
|
||||
* children own their own chrome.
|
||||
*/
|
||||
function ResourceRoot({ children, onContextMenu }: ResourceProps) {
|
||||
return (
|
||||
<div
|
||||
className='relative flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Imperative handle for `Resource.Table`. Lets a consumer drive virtualizer-aware
|
||||
* scrolling — required for keyboard navigation, since a `querySelector` on the
|
||||
* selected row's DOM node silently no-ops once that row is windowed out.
|
||||
*/
|
||||
export interface ResourceTableHandle {
|
||||
/** Scroll the row with the given id into view via the virtualizer (works even when the row is not in the DOM). */
|
||||
scrollToRow: (rowId: string) => void
|
||||
}
|
||||
|
||||
interface ResourceTableProps {
|
||||
columns: ResourceColumn[]
|
||||
rows: ResourceRow[]
|
||||
selectedRowId?: string | null
|
||||
/** Optional imperative handle exposing {@link ResourceTableHandle} (e.g. for keyboard-nav scrolling). */
|
||||
apiRef?: RefObject<ResourceTableHandle | null>
|
||||
/**
|
||||
* Window the row list with `@tanstack/react-virtual`, keeping only the visible
|
||||
* slice in the DOM. Opt-in because it removes off-screen rows — consumers that
|
||||
* depend on every row being mounted (e.g. drag-and-drop drop targets) must stay
|
||||
* on the full-DOM path. Enable it only for unbounded, accumulating lists (logs).
|
||||
*/
|
||||
virtualized?: boolean
|
||||
selectable?: SelectableConfig
|
||||
rowDragDrop?: RowDragDropConfig
|
||||
onRowClick?: (rowId: string) => void
|
||||
onRowHover?: (rowId: string) => void
|
||||
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
|
||||
onLoadMore?: () => void
|
||||
hasMore?: boolean
|
||||
isLoadingMore?: boolean
|
||||
pagination?: PaginationConfig
|
||||
/**
|
||||
* Sanctioned overlay slot. Rendered absolutely against the table region
|
||||
* (action bars, slide-out sidebars, drop targets). The overlay owns its own
|
||||
* chrome and positioning; it never alters the table's rendering.
|
||||
*/
|
||||
overlay?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Data table body, module-private and exposed only as `Resource.Table` — the
|
||||
* compound member is the sole way consumers render it.
|
||||
*
|
||||
* Chrome guarantee: the table region and column headers render unconditionally —
|
||||
* no prop or row state (empty, loading, error) ever drops them. Structural
|
||||
* additions (checkbox column, load-more sentinel, pagination bar) are driven
|
||||
* purely by which configs the consumer supplies and always render the canonical
|
||||
* chrome.
|
||||
*
|
||||
* The table is built from `<div>`s carrying explicit ARIA roles (`table`,
|
||||
* `rowgroup`, `row`, `columnheader`, `cell`) rather than native table elements:
|
||||
* the rows use CSS grid for column alignment, and `display: grid` on a native
|
||||
* `<table>` strips its implicit table semantics, so the roles are declared
|
||||
* directly. Column widths come from a shared grid track list (see
|
||||
* {@link buildGridTemplateColumns}) reproducing the legacy `<colgroup>` ratios.
|
||||
* When `virtualized`, the body windows with `@tanstack/react-virtual` so only
|
||||
* the visible row slice is in the DOM, bounding DOM size and memory on lists
|
||||
* that accumulate many pages.
|
||||
*/
|
||||
const ResourceTable = memo(function ResourceTable({
|
||||
columns,
|
||||
rows,
|
||||
selectedRowId,
|
||||
apiRef,
|
||||
virtualized = false,
|
||||
selectable,
|
||||
rowDragDrop,
|
||||
onRowClick,
|
||||
onRowHover,
|
||||
onRowContextMenu,
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
pagination,
|
||||
overlay,
|
||||
}: ResourceTableProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [contextMenuRowId, setContextMenuRowId] = useState<string | null>(null)
|
||||
|
||||
const wrappedOnRowContextMenu = useCallback(
|
||||
(e: React.MouseEvent, rowId: string) => {
|
||||
setContextMenuRowId(rowId)
|
||||
onRowContextMenu?.(e, rowId)
|
||||
},
|
||||
[onRowContextMenu]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuRowId) return
|
||||
const clear = () => setContextMenuRowId(null)
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
clear()
|
||||
}
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener('pointerdown', clear, { once: true })
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
}, 0)
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
document.removeEventListener('pointerdown', clear)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [contextMenuRowId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!onLoadMore || !hasMore) return
|
||||
const el = loadMoreRef.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) onLoadMore()
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [onLoadMore, hasMore])
|
||||
|
||||
const hasCheckbox = selectable != null
|
||||
|
||||
const handleSelectAll = useCallback(
|
||||
(checked: boolean | 'indeterminate') => {
|
||||
selectable?.onSelectAll(checked as boolean)
|
||||
},
|
||||
[selectable]
|
||||
)
|
||||
|
||||
const gridTemplateColumns = useMemo(
|
||||
() => buildGridTemplateColumns(columns, hasCheckbox),
|
||||
[columns, hasCheckbox]
|
||||
)
|
||||
|
||||
/**
|
||||
* Windows the row list so only the visible slice (plus overscan) is in the
|
||||
* DOM, bounding DOM size and memory regardless of how many pages a consumer
|
||||
* accumulates. Rows are measured via {@link rowVirtualizer.measureElement} so
|
||||
* any single-line height variance stays pixel-exact.
|
||||
*/
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT_ESTIMATE,
|
||||
overscan: ROW_OVERSCAN,
|
||||
getItemKey: (index) => rows[index].id,
|
||||
})
|
||||
|
||||
useImperativeHandle(
|
||||
apiRef,
|
||||
() => ({
|
||||
scrollToRow: (rowId: string) => {
|
||||
const index = rows.findIndex((row) => row.id === rowId)
|
||||
if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: 'auto' })
|
||||
},
|
||||
}),
|
||||
[rows, rowVirtualizer]
|
||||
)
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems()
|
||||
const totalSize = rowVirtualizer.getTotalSize()
|
||||
|
||||
return (
|
||||
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
|
||||
<div ref={scrollRef} className='min-h-0 flex-1 overflow-auto overscroll-none'>
|
||||
<div role='table' className='grid w-full text-small'>
|
||||
<div
|
||||
role='rowgroup'
|
||||
className='sticky top-0 z-10 grid bg-[var(--bg)] shadow-[inset_0_-1px_0_var(--border)]'
|
||||
>
|
||||
<div role='row' className='grid' style={{ gridTemplateColumns }}>
|
||||
{hasCheckbox && (
|
||||
<div
|
||||
role='columnheader'
|
||||
className='flex h-10 items-center py-1.5 pr-0 pl-5 text-left'
|
||||
>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={selectable.isAllSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
disabled={selectable.disabled}
|
||||
aria-label='Select all'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.id}
|
||||
role='columnheader'
|
||||
className='flex h-10 min-w-0 items-center px-6 py-1.5 text-left font-normal text-[var(--text-muted)] text-small'
|
||||
>
|
||||
<span className='min-w-0 truncate'>{col.header}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role='rowgroup'
|
||||
className={cn('grid', virtualized && 'relative')}
|
||||
style={virtualized ? { height: totalSize } : undefined}
|
||||
>
|
||||
{virtualized
|
||||
? virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]
|
||||
return (
|
||||
<DataRow
|
||||
key={virtualRow.key}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
dataIndex={virtualRow.index}
|
||||
translateY={virtualRow.start}
|
||||
gridTemplateColumns={gridTemplateColumns}
|
||||
row={row}
|
||||
columns={columns}
|
||||
selectedRowId={selectedRowId}
|
||||
selectable={selectable}
|
||||
rowDragDrop={rowDragDrop}
|
||||
onRowClick={onRowClick}
|
||||
onRowHover={onRowHover}
|
||||
onRowContextMenu={onRowContextMenu ? wrappedOnRowContextMenu : undefined}
|
||||
isContextMenuTarget={contextMenuRowId === row.id}
|
||||
hasCheckbox={hasCheckbox}
|
||||
/>
|
||||
)
|
||||
})
|
||||
: rows.map((row) => (
|
||||
<DataRow
|
||||
key={row.id}
|
||||
gridTemplateColumns={gridTemplateColumns}
|
||||
row={row}
|
||||
columns={columns}
|
||||
selectedRowId={selectedRowId}
|
||||
selectable={selectable}
|
||||
rowDragDrop={rowDragDrop}
|
||||
onRowClick={onRowClick}
|
||||
onRowHover={onRowHover}
|
||||
onRowContextMenu={onRowContextMenu ? wrappedOnRowContextMenu : undefined}
|
||||
isContextMenuTarget={contextMenuRowId === row.id}
|
||||
hasCheckbox={hasCheckbox}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div ref={loadMoreRef} className='flex items-center justify-center py-3'>
|
||||
{isLoadingMore && (
|
||||
<Loader className='size-[16px] text-[var(--text-secondary)]' animate />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{overlay}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={pagination.onPageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const Pagination = memo(function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className='flex items-center justify-center border-[var(--border)] border-t bg-[var(--bg)] px-4 py-2.5'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
<ChevronLeft className='size-3.5' />
|
||||
</Button>
|
||||
<div className='mx-3 flex items-center gap-4'>
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
let page: number
|
||||
if (totalPages <= 5) {
|
||||
page = i + 1
|
||||
} else if (currentPage <= 3) {
|
||||
page = i + 1
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
page = totalPages - 4 + i
|
||||
} else {
|
||||
page = currentPage - 2 + i
|
||||
}
|
||||
if (page < 1 || page > totalPages) return null
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => onPageChange(page)}
|
||||
className={cn(
|
||||
'h-auto p-0 font-medium text-sm transition-colors hover-hover:bg-transparent hover-hover:text-[var(--text-body)]',
|
||||
page === currentPage ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]'
|
||||
)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
<ChevronRight className='size-3.5' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
interface CellContentProps {
|
||||
/** Pre-rendered icon node (svg/img/span avatar); auto-sized to the chip icon size. */
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
content?: ReactNode
|
||||
editing?: ResourceCellEditing
|
||||
}
|
||||
|
||||
const CellContent = memo(function CellContent({ icon, label, content, editing }: CellContentProps) {
|
||||
if (editing) {
|
||||
return (
|
||||
<span className={cn('flex min-w-0 items-center', chipContentGap)}>
|
||||
{icon && <span className={cellIconNodeClass}>{icon}</span>}
|
||||
<InlineRenameInput
|
||||
value={editing.value}
|
||||
onChange={editing.onChange}
|
||||
onSubmit={editing.onSubmit}
|
||||
onCancel={editing.onCancel}
|
||||
disabled={editing.disabled}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (content) return <>{content}</>
|
||||
return (
|
||||
<span className={cn('flex min-w-0 items-center', chipContentGap)}>
|
||||
{icon && <span className={cellIconNodeClass}>{icon}</span>}
|
||||
<FloatingOverflowText label={label} className={cn('block', chipContentLabelClass)} />
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
interface DataRowProps {
|
||||
row: ResourceRow
|
||||
columns: ResourceColumn[]
|
||||
selectedRowId?: string | null
|
||||
selectable?: SelectableConfig
|
||||
rowDragDrop?: RowDragDropConfig
|
||||
onRowClick?: (rowId: string) => void
|
||||
onRowHover?: (rowId: string) => void
|
||||
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
|
||||
isContextMenuTarget?: boolean
|
||||
hasCheckbox: boolean
|
||||
/** CSS grid track list shared with the header so columns stay aligned. */
|
||||
gridTemplateColumns: string
|
||||
/**
|
||||
* Virtual row offset. When set, the row is absolutely positioned within the
|
||||
* sized tbody (windowed mode); when omitted, the row renders in normal grid
|
||||
* flow (full-DOM mode).
|
||||
*/
|
||||
translateY?: number
|
||||
/** Virtual index, consumed by the virtualizer's `measureElement` ref (windowed mode only). */
|
||||
dataIndex?: number
|
||||
/** Forwarded from the virtualizer so each mounted row is measured exactly (windowed mode only). */
|
||||
ref?: (node: HTMLDivElement | null) => void
|
||||
}
|
||||
|
||||
const DataRow = memo(function DataRow({
|
||||
row,
|
||||
columns,
|
||||
selectedRowId,
|
||||
selectable,
|
||||
rowDragDrop,
|
||||
onRowClick,
|
||||
onRowHover,
|
||||
onRowContextMenu,
|
||||
isContextMenuTarget,
|
||||
hasCheckbox,
|
||||
gridTemplateColumns,
|
||||
translateY,
|
||||
dataIndex,
|
||||
ref,
|
||||
}: DataRowProps) {
|
||||
const isSelected = selectable?.selectedIds.has(row.id) ?? false
|
||||
const isDraggable = rowDragDrop?.isRowDraggable?.(row.id) ?? false
|
||||
const isDropTarget = rowDragDrop?.isRowDropTarget?.(row.id) ?? false
|
||||
const isActiveDropTarget = rowDragDrop?.activeDropTargetId === row.id
|
||||
const isDragging = rowDragDrop?.draggedRowIds?.has(row.id) ?? false
|
||||
const isAnyDragActive = rowDragDrop?.isAnyDragActive ?? false
|
||||
const hasActiveSelection = (selectable?.selectedIds.size ?? 0) > 0
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
selectable &&
|
||||
!selectable.disabled &&
|
||||
(e.shiftKey || e.metaKey || e.ctrlKey || !onRowClick || hasActiveSelection)
|
||||
) {
|
||||
e.preventDefault()
|
||||
selectable.onSelectRow(row.id, !isSelected, e.shiftKey)
|
||||
return
|
||||
}
|
||||
onRowClick?.(row.id)
|
||||
},
|
||||
[hasActiveSelection, isSelected, onRowClick, row.id, selectable]
|
||||
)
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
onRowHover?.(row.id)
|
||||
}, [onRowHover, row.id])
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
onRowContextMenu?.(e, row.id)
|
||||
},
|
||||
[onRowContextMenu, row.id]
|
||||
)
|
||||
|
||||
const shiftKeyRef = useRef(false)
|
||||
|
||||
const handleSelectRowClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
shiftKeyRef.current = e.shiftKey
|
||||
}, [])
|
||||
|
||||
const handleSelectRow = useCallback(
|
||||
(checked: boolean | 'indeterminate') => {
|
||||
selectable?.onSelectRow(row.id, checked as boolean, shiftKeyRef.current)
|
||||
shiftKeyRef.current = false
|
||||
},
|
||||
[selectable, row.id]
|
||||
)
|
||||
|
||||
const handleDragStart = (e: DragEvent<HTMLDivElement>) => {
|
||||
rowDragDrop?.onDragStart?.(e, row.id)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
rowDragDrop?.onDragOver?.(e, row.id)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
rowDragDrop?.onDragLeave?.(e, row.id)
|
||||
}
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
rowDragDrop?.onDrop?.(e, row.id)
|
||||
}
|
||||
|
||||
const handleDragEnd = (e: DragEvent<HTMLDivElement>) => {
|
||||
rowDragDrop?.onDragEnd?.(e, row.id)
|
||||
}
|
||||
|
||||
const isWindowed = translateY !== undefined
|
||||
const rowStyle: CSSProperties = isWindowed
|
||||
? { gridTemplateColumns, transform: `translateY(${translateY}px)` }
|
||||
: { gridTemplateColumns }
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role='row'
|
||||
data-index={dataIndex}
|
||||
data-resource-row
|
||||
data-row-id={row.id}
|
||||
className={cn(
|
||||
'grid w-full transition-colors',
|
||||
isWindowed && 'absolute top-0 left-0',
|
||||
!isAnyDragActive && 'hover-hover:bg-[var(--surface-3)]',
|
||||
onRowClick && 'cursor-pointer',
|
||||
isDraggable && 'cursor-grab active:cursor-grabbing',
|
||||
isDropTarget && 'data-[drop-target=true]:outline-offset-[-1px]',
|
||||
(selectedRowId === row.id || isSelected || isContextMenuTarget) && 'bg-[var(--surface-3)]',
|
||||
isActiveDropTarget && 'bg-[var(--surface-4)] outline outline-1 outline-[var(--accent)]',
|
||||
(isDragging || (isAnyDragActive && isSelected && !isActiveDropTarget)) && 'opacity-50'
|
||||
)}
|
||||
style={rowStyle}
|
||||
data-drop-target={isDropTarget || undefined}
|
||||
draggable={isDraggable}
|
||||
onClick={onRowClick || selectable ? handleClick : undefined}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onContextMenu={onRowContextMenu ? handleContextMenu : undefined}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragOver={isDropTarget ? handleDragOver : undefined}
|
||||
onDragLeave={isDropTarget ? handleDragLeave : undefined}
|
||||
onDrop={isDropTarget ? handleDrop : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
{hasCheckbox && selectable && (
|
||||
<div role='cell' className='flex items-center py-2.5 pr-0 pl-5'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={isSelected}
|
||||
onCheckedChange={handleSelectRow}
|
||||
disabled={selectable.disabled}
|
||||
aria-label='Select row'
|
||||
onClick={handleSelectRowClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{columns.map((col) => {
|
||||
const cell = row.cells[col.id]
|
||||
return (
|
||||
<div key={col.id} role='cell' className='flex min-w-0 items-center px-6 py-2.5'>
|
||||
<CellContent
|
||||
icon={cell?.icon}
|
||||
label={cell?.label || EMPTY_CELL_PLACEHOLDER}
|
||||
content={cell?.content}
|
||||
editing={cell?.editing}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* The single public entry point. `Resource` is the layout shell; its compound
|
||||
* members are the only building blocks consumers compose. Import `Resource` and
|
||||
* nothing else from this module.
|
||||
*/
|
||||
export const Resource = Object.assign(ResourceRoot, {
|
||||
Header: ResourceHeader,
|
||||
Options: ResourceOptions,
|
||||
Table: ResourceTable,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { SkillTile } from './skill-tile'
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AgentSkillsIcon } from '@/components/icons'
|
||||
|
||||
/**
|
||||
* Square tile bearing the agent-skills glyph. Shared chrome for any surface
|
||||
* that lists a skill (the Skills page and integration detail pages) so the two
|
||||
* do not drift.
|
||||
*/
|
||||
export function SkillTile() {
|
||||
return (
|
||||
<div className='size-9 flex-shrink-0'>
|
||||
<div className='flex size-full items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]'>
|
||||
<AgentSkillsIcon className='size-5 text-[var(--text-icon)]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { WorkspaceChrome } from './workspace-chrome'
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
import { useFullscreenOriginStore } from '@/stores/fullscreen-origin'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
const FULLSCREEN_SUFFIXES = ['/upgrade'] as const
|
||||
|
||||
/** Slide timing for the fullscreen sidebar collapse and content shift. */
|
||||
const SLIDE_TRANSITION =
|
||||
'duration-[175ms] ease-[cubic-bezier(0.25,0.1,0.25,1)] motion-reduce:transition-none'
|
||||
|
||||
interface WorkspaceChromeProps {
|
||||
children: React.ReactNode
|
||||
/** Cookie-derived collapse state from the server layout; seeds the sidebar's first render. */
|
||||
initialSidebarCollapsed?: boolean
|
||||
}
|
||||
|
||||
function isFullscreenPath(pathname: string | null): boolean {
|
||||
return FULLSCREEN_SUFFIXES.some((s) => pathname?.endsWith(s))
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the workspace chrome as a single persistent tree. The sidebar is
|
||||
* always mounted; on a fullscreen route (`/upgrade`) its wrapper collapses to
|
||||
* zero width while the inner shell slides off the left edge, revealing the route
|
||||
* content. Because this component lives in the workspace layout it persists
|
||||
* across navigations, so the pathname-driven class toggle animates smoothly.
|
||||
*
|
||||
* Leaving a fullscreen route is instant: App Router swaps `children` to the
|
||||
* origin page and the fullscreen page is simply unmounted, while the sidebar
|
||||
* slides back in. There is no exit fade — the new page just loads in place.
|
||||
*
|
||||
* Because the chrome observes every pathname transition, it records the page a
|
||||
* fullscreen route was launched from into {@link useFullscreenOriginStore}. The
|
||||
* route's Back control reads that origin to return deterministically, so any
|
||||
* trigger that merely pushes a fullscreen route gets correct return-to-origin
|
||||
* without per-call-site wiring.
|
||||
*
|
||||
* On a direct load of a fullscreen route the wrapper mounts already collapsed,
|
||||
* so no slide plays (CSS transitions don't run on mount).
|
||||
*/
|
||||
export function WorkspaceChrome({
|
||||
children,
|
||||
initialSidebarCollapsed = false,
|
||||
}: WorkspaceChromeProps) {
|
||||
const rafRef = useRef(0)
|
||||
|
||||
const pathname = usePathname()
|
||||
const isFullscreen = isFullscreenPath(pathname)
|
||||
|
||||
const setOrigin = useFullscreenOriginStore((s) => s.setOrigin)
|
||||
|
||||
const storeIsCollapsed = useSidebarStore((s) => s.isCollapsed)
|
||||
const hasHydrated = useSidebarStore((s) => s._hasHydrated)
|
||||
const syncSidebarWidth = useSidebarStore((s) => s.syncWidth)
|
||||
|
||||
/**
|
||||
* Single source of collapse for the whole chrome, driving the rail's structure,
|
||||
* labels, and width. The server renders from the `sidebar_collapsed` cookie
|
||||
* (`initialSidebarCollapsed`) and the store seeds from the same cookie — after
|
||||
* the pre-paint script migrates any legacy `localStorage` flag — so prop and
|
||||
* store agree. The prop is used until the store hydrates (keeping the first
|
||||
* client render identical to the server), then the store takes over.
|
||||
*/
|
||||
const isCollapsed = hasHydrated ? storeIsCollapsed : initialSidebarCollapsed
|
||||
|
||||
/**
|
||||
* Suppresses sidebar transitions across the initial hydration window. The
|
||||
* pre-paint script already set the correct `--sidebar-width`, but the store
|
||||
* rehydration below re-applies it a tick later; without this guard that
|
||||
* re-apply animates the rail, reading as a collapse -> expand flash on a
|
||||
* fresh load. Applied before the rehydrate effect so the class is in place
|
||||
* ahead of the width mutation, then lifted after the first paint so
|
||||
* user-driven collapse toggles and the fullscreen slide still animate.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
const root = document.documentElement
|
||||
root.classList.add('sidebar-booting')
|
||||
const raf1 = requestAnimationFrame(() => {
|
||||
const raf2 = requestAnimationFrame(() => root.classList.remove('sidebar-booting'))
|
||||
rafRef.current = raf2
|
||||
})
|
||||
rafRef.current = raf1
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
root.classList.remove('sidebar-booting')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Hydrate the persisted width before paint (collapse comes from the cookie/prop).
|
||||
useLayoutEffect(() => {
|
||||
void useSidebarStore.persist.rehydrate()
|
||||
}, [])
|
||||
|
||||
// Remember the last non-fullscreen page so a fullscreen route's Back control
|
||||
// can return there, deterministically and for any trigger.
|
||||
useEffect(() => {
|
||||
if (pathname && !isFullscreen) setOrigin(pathname)
|
||||
}, [pathname, isFullscreen, setOrigin])
|
||||
|
||||
// Re-apply the sidebar width whenever this persistent shell sees a navigation.
|
||||
// The blocking script in the document head only runs on full page loads and
|
||||
// store rehydration only fires once, so a soft navigation can leave
|
||||
// `--sidebar-width` stuck at its `0px` default — collapsing the sidebar to
|
||||
// nothing with no reachable control to bring it back. Re-syncing here recovers
|
||||
// that state. Gated on hydration so it never clobbers the persisted value with
|
||||
// store defaults during the pre-hydration window.
|
||||
useEffect(() => {
|
||||
if (hasHydrated) syncSidebarWidth()
|
||||
}, [pathname, hasHydrated, syncSidebarWidth])
|
||||
|
||||
// Re-clamp the width when the window shrinks below what the persisted width
|
||||
// allows, so the sidebar can never grow wider than the viewport permits.
|
||||
useEffect(() => {
|
||||
let rafId: number | null = null
|
||||
const onResize = () => {
|
||||
if (rafId !== null) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
syncSidebarWidth()
|
||||
})
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||
window.removeEventListener('resize', onResize)
|
||||
}
|
||||
}, [syncSidebarWidth])
|
||||
|
||||
return (
|
||||
<div className='flex min-h-0 flex-1'>
|
||||
<div
|
||||
className={cn(
|
||||
'sidebar-shell-outer shrink-0 overflow-hidden transition-[width]',
|
||||
SLIDE_TRANSITION,
|
||||
isFullscreen ? 'w-0' : 'w-[var(--sidebar-width)]'
|
||||
)}
|
||||
data-collapsed={isCollapsed || undefined}
|
||||
aria-hidden={isFullscreen || undefined}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'sidebar-shell-inner h-full w-[var(--sidebar-width)] shrink-0 transition-transform',
|
||||
SLIDE_TRANSITION,
|
||||
isFullscreen && '-translate-x-full'
|
||||
)}
|
||||
>
|
||||
<Sidebar isCollapsed={isCollapsed} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 flex-col p-[8px] transition-[padding]',
|
||||
SLIDE_TRANSITION,
|
||||
!isFullscreen && 'pl-0'
|
||||
)}
|
||||
>
|
||||
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user