- {/* Header */}
-
-
Log Details
-
- {log.status === 'failed' &&
- (log.workflow?.id || log.workflowId) &&
- log.trigger !== 'mothership' && (
-
-
-
-
- Retry
-
- )}
-
-
-
-
+
+ {log && (
+
+ {/* Header */}
+
+
Log Details
+
+ {log.status === 'failed' &&
+ (log.workflow?.id || log.workflowId) &&
+ log.trigger !== 'mothership' && (
+
+
+
+
+ Retry
+
+ )}
+
+
+
-
-
- )}
-
- >
+
+
+
+ )}
+
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx
index e2227fb5be1..5ed069adc55 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
-import { Badge, Button, ChipModalTabs, cn, X } from '@sim/emcn'
+import { Badge, Button, ChipModalTabs, cn, DetailsPanel, X } from '@sim/emcn'
import { formatDuration } from '@sim/utils/formatting'
import type { EnrichmentProviderOutcome, EnrichmentRunDetail } from '@/lib/table'
import {
@@ -340,45 +340,31 @@ export function EnrichmentDetails({
}, [isOpen, onClose])
return (
- <>
- {isOpen && (
-
- )}
-
-
- {rowId && groupId && (
-
-
-
Enrichment Details
-
-
-
-
+
+ {rowId && groupId && (
+
+
+
Enrichment Details
+
- )}
-
- >
+
+
+
+ )}
+
)
}
diff --git a/packages/emcn/src/components/details-panel/details-panel.test.tsx b/packages/emcn/src/components/details-panel/details-panel.test.tsx
new file mode 100644
index 00000000000..3b8f9f4943e
--- /dev/null
+++ b/packages/emcn/src/components/details-panel/details-panel.test.tsx
@@ -0,0 +1,60 @@
+/** @vitest-environment jsdom */
+import { act, createRef } from 'react'
+import { DetailsPanel, type DetailsPanelProps } from '@sim/emcn'
+import { createRoot } from 'react-dom/client'
+import { expect, it, vi } from 'vitest'
+
+it('forwards resize events and retains content and refs while closed', () => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ const ref = createRef
()
+ const resize = vi.fn()
+ const render = (open: boolean, width: DetailsPanelProps['width'] = 520) => (
+
+
+
+ )
+
+ try {
+ act(() => root.render(render(true)))
+ const panel = ref.current!
+ expect(panel.style.getPropertyValue('--details-panel-width')).toBe('520px')
+ expect(panel.style.width).toBe('')
+ expect(panel.style.opacity).toBe('0.9')
+ expect(panel.hasAttribute('inert')).toBe(false)
+ const input = panel.querySelector('input')!
+ const handle = container.querySelector('[role="separator"]')!
+ expect(handle.style.getPropertyValue('--details-panel-width')).toBe('520px')
+ expect(handle.getAttribute('aria-label')).toBe('Resize details')
+ expect(panel.contains(handle)).toBe(false)
+ act(() => handle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 240 })))
+ expect(resize).toHaveBeenCalledTimes(1)
+ expect(resize.mock.calls[0][0].clientX).toBe(240)
+
+ act(() => root.render(render(false)))
+ expect(panel.hasAttribute('inert')).toBe(true)
+ expect(ref.current).toBe(panel)
+ expect(panel.querySelector('input')).toBe(input)
+ expect(input.value).toBe('Retained query')
+ expect(container.querySelector('[role="separator"]')).toBeNull()
+ const responsiveWidth = 'clamp(min(320px, 60vw), 520px, 60vw)'
+ act(() => root.render(render(true, responsiveWidth)))
+ expect(panel.style.getPropertyValue('--details-panel-width')).toBe(responsiveWidth)
+ expect(panel.hasAttribute('inert')).toBe(false)
+ expect(panel.querySelector('input')).toBe(input)
+ expect(container.querySelector('[role="separator"]')).not.toBeNull()
+ } finally {
+ act(() => root.unmount())
+ container.remove()
+ }
+})
diff --git a/packages/emcn/src/components/details-panel/details-panel.tsx b/packages/emcn/src/components/details-panel/details-panel.tsx
new file mode 100644
index 00000000000..83453c857d5
--- /dev/null
+++ b/packages/emcn/src/components/details-panel/details-panel.tsx
@@ -0,0 +1,62 @@
+import { type CSSProperties, forwardRef, type HTMLAttributes, type MouseEventHandler } from 'react'
+import { cn } from '../../lib/cn'
+
+export interface DetailsPanelProps extends HTMLAttributes {
+ /** Slide the panel into view; closed content stays mounted but inert. */
+ open: boolean
+ /** Controlled width, including responsive CSS expressions such as clamp(). */
+ width: NonNullable
+ /** Existing product resize handler; width state stays with the caller. */
+ onResizeStart: MouseEventHandler
+ /** Accessible name for the external resize handle. */
+ resizeLabel: string
+}
+
+/**
+ * Non-modal details sidebar with a right-edge slide and an external resize handle.
+ * Its parent supplies the positioned containing block. Content, keyboard commands,
+ * and persisted width remain owned by the caller.
+ *
+ * @example
+ *
+ * {content}
+ *
+ */
+export const DetailsPanel = forwardRef(
+ ({ open, width, onResizeStart, resizeLabel, className, style, children, ...props }, ref) => {
+ const cssWidth = typeof width === 'number' ? `${width}px` : width
+ const widthStyle = { '--details-panel-width': cssWidth } as CSSProperties
+ const { width: _styleWidth, ...panelStyle } = style ?? {}
+
+ return (
+ <>
+ {open && (
+
+ )}
+
+ {children}
+
+ >
+ )
+ }
+)
+
+DetailsPanel.displayName = 'DetailsPanel'
diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts
index c7c2204968a..6bf14191b56 100644
--- a/packages/emcn/src/components/index.ts
+++ b/packages/emcn/src/components/index.ts
@@ -130,6 +130,7 @@ export {
type ComposerActionButtonProps,
composerActionButtonVariants,
} from './composer-action-button/composer-action-button'
+export { DetailsPanel, type DetailsPanelProps } from './details-panel/details-panel'
export {
DropdownMenu,
DropdownMenuCheckboxItem,