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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,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'
@@ -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'
@@ -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>
)
}
@@ -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)
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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 }}
/>
)
}
@@ -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,
}
}
@@ -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'
@@ -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'
@@ -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>
</>
)
})
@@ -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} />
</>
)
})
@@ -0,0 +1 @@
export { ownerCell } from './owner-cell'
@@ -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,
}
}
@@ -0,0 +1,2 @@
export type { ChromeActionSpec } from './resource-chrome-fallback'
export { ResourceChromeFallback } from './resource-chrome-fallback'
@@ -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>
)
}
@@ -0,0 +1,7 @@
export type {
BreadcrumbEditing,
BreadcrumbItem,
DropdownOption,
ResourceAction,
} from './resource-header'
export { ResourceHeader } from './resource-header'
@@ -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
)
}
@@ -0,0 +1,9 @@
export type {
ColumnOption,
FilterConfig,
FilterTag,
SearchConfig,
SearchTag,
SortConfig,
} from './resource-options'
export { ResourceOptions, SortDropdown } from './resource-options'
@@ -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'
@@ -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>
)
}