diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx
index 1fbe5068162..60b2b06d8c5 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx
@@ -48,7 +48,7 @@ export const FilesListContextMenu = memo(function FilesListContextMenu({
{onUploadFile && (
- Upload file
+ Upload
)}
{onCreateFolder && (
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx
new file mode 100644
index 00000000000..8eea65cc85f
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx
@@ -0,0 +1,678 @@
+/**
+ * @vitest-environment jsdom
+ */
+import {
+ act,
+ Children,
+ type ComponentType,
+ cloneElement,
+ isValidElement,
+ type ReactElement,
+ type ReactNode,
+} from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+import type {
+ ShareAuthType,
+ ShareRecord,
+ UpsertFileShareBody,
+} from '@/lib/api/contracts/public-shares'
+
+interface MockMutationVariables extends UpsertFileShareBody {
+ workspaceId: string
+ fileId: string
+}
+
+interface MockMutationCallbacks {
+ onSuccess?: () => void
+}
+
+interface MockButtonGroupItemProps {
+ value: string
+ children: ReactNode
+ selectedValue?: string
+ onSelect?: (value: string) => void
+ disabled?: boolean
+}
+
+interface MockFooterAction {
+ label: ReactNode
+ onClick: () => void
+ disabled?: boolean
+ variant?: 'primary' | 'destructive'
+}
+
+type MockFooterSlot = MockFooterAction | { custom: ReactNode }
+
+const {
+ fileShareQueryState,
+ fileShareState,
+ mockCopy,
+ mockGenerateShortId,
+ mockMutate,
+ mockToastSuccess,
+ mutationState,
+ permissionConfigState,
+} = vi.hoisted(() => ({
+ fileShareQueryState: { isFetchedAfterMount: true, isError: false },
+ fileShareState: { current: null as ShareRecord | null },
+ mockCopy: vi.fn(async () => true),
+ mockGenerateShortId: vi.fn(() => 'pending-token-1234567890'),
+ mockMutate: vi.fn(),
+ mockToastSuccess: vi.fn(),
+ mutationState: { isPending: false },
+ permissionConfigState: {
+ current: {
+ allowedFileShareAuthTypes: null as ShareAuthType[] | null,
+ disablePublicFileSharing: false,
+ },
+ },
+}))
+
+vi.mock('@sim/utils/id', () => ({
+ generateShortId: mockGenerateShortId,
+}))
+
+vi.mock('@sim/emcn/icons', () => ({
+ Check: () =>
,
+ Link: () =>
,
+ Send: () =>
,
+}))
+
+vi.mock('@sim/emcn', () => ({
+ toast: { success: mockToastSuccess },
+ ButtonGroup: ({
+ children,
+ value,
+ onValueChange,
+ disabled,
+ 'aria-label': ariaLabel,
+ }: {
+ children: ReactNode
+ value: string
+ onValueChange: (value: string) => void
+ disabled?: boolean
+ 'aria-label'?: string
+ }) => (
+
+ {Children.map(children, (child) =>
+ isValidElement(child)
+ ? cloneElement(child as ReactElement, {
+ selectedValue: value,
+ onSelect: onValueChange,
+ disabled,
+ })
+ : child
+ )}
+
+ ),
+ ButtonGroupItem: ({
+ value,
+ children,
+ selectedValue,
+ onSelect,
+ disabled,
+ }: MockButtonGroupItemProps) => (
+
+ ),
+ Chip: ({
+ children,
+ leftIcon: LeftIcon,
+ onClick,
+ disabled,
+ }: {
+ children: ReactNode
+ leftIcon?: ComponentType<{ className?: string }>
+ onClick?: () => void
+ disabled?: boolean
+ }) => (
+
+ ),
+ ChipModal: ({
+ open,
+ children,
+ dismissDisabled,
+ className,
+ }: {
+ open: boolean
+ children: ReactNode
+ dismissDisabled?: boolean
+ className?: string
+ }) =>
+ open ? (
+
+ {children}
+
+ ) : null,
+ ChipConfirmModal: ({
+ open,
+ onOpenChange,
+ title,
+ text,
+ confirm,
+ }: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ title: ReactNode
+ text?: ReactNode
+ confirm: MockFooterAction & { pending?: boolean; pendingLabel?: string }
+ }) =>
+ open ? (
+
+ {title}
+ {text ? {text}
: null}
+
+
+
+ ) : null,
+ ChipModalHeader: ({ children, onClose }: { children: ReactNode; onClose: () => void }) => (
+
+ {children}
+
+
+ ),
+ ChipModalBody: ({ children, className }: { children: ReactNode; className?: string }) => (
+
+ {children}
+
+ ),
+ ChipModalField: ({
+ type,
+ title,
+ children,
+ value,
+ onChange,
+ hint,
+ disabled,
+ }: {
+ type: string
+ title: string
+ children?: ReactNode
+ value?: string[]
+ onChange?: (value: string[]) => void
+ hint?: ReactNode
+ disabled?: boolean
+ }) => (
+
+ ),
+ ChipModalFooter: ({
+ onCancel,
+ primaryAction,
+ secondaryActions,
+ }: {
+ onCancel: () => void
+ primaryAction: MockFooterAction
+ secondaryActions?: MockFooterSlot[]
+ }) => (
+
+ ),
+ useCopyToClipboard: () => ({ copied: false, copy: mockCopy }),
+}))
+
+vi.mock('@/components/ui', () => ({
+ GeneratedPasswordInput: ({
+ value,
+ onChange,
+ placeholder,
+ disabled,
+ }: {
+ value: string
+ onChange: (value: string) => void
+ placeholder?: string
+ disabled?: boolean
+ }) => (
+
onChange(event.target.value)}
+ disabled={disabled}
+ />
+ ),
+}))
+
+vi.mock('@/lib/core/config/env-flags', () => ({ isSsoEnabled: true }))
+vi.mock('@/lib/messaging/email/validation', () => ({
+ validateAllowlistEntry: () => null,
+}))
+vi.mock('@/hooks/use-permission-config', () => ({
+ usePermissionConfig: () => ({
+ config: permissionConfigState.current,
+ }),
+}))
+vi.mock('@/hooks/queries/public-shares', () => ({
+ useFileShare: () => ({ data: fileShareState.current, ...fileShareQueryState }),
+ useUpsertFileShare: () => ({
+ mutate: mockMutate,
+ isPending: mutationState.isPending,
+ }),
+}))
+
+import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal/share-modal'
+
+const SHARE_URL = 'https://sim.example.com/f/persisted-token'
+
+function createShare(overrides: Partial
= {}): ShareRecord {
+ return {
+ id: 'share-1',
+ token: 'persisted-token',
+ url: SHARE_URL,
+ isActive: true,
+ resourceType: 'file',
+ resourceId: 'file-1',
+ authType: 'public',
+ hasPassword: false,
+ allowedEmails: [],
+ ...overrides,
+ }
+}
+
+let container: HTMLDivElement
+let onOpenChange: ReturnType void>>
+let root: Root
+
+async function renderModal(initialShare: ShareRecord | null = null) {
+ await act(async () => {
+ root.render(
+
+ )
+ })
+}
+
+function button(label: string): HTMLButtonElement {
+ const match = [...container.querySelectorAll('button')].find(
+ (candidate) => candidate.textContent === label
+ )
+ if (!match) throw new Error(`No button labelled "${label}"`)
+ return match
+}
+
+function queryButton(label: string): HTMLButtonElement | undefined {
+ return [...container.querySelectorAll('button')].find(
+ (candidate) => candidate.textContent === label
+ )
+}
+
+async function click(label: string) {
+ await act(async () => button(label).click())
+}
+
+async function clickConfirmation(label: string) {
+ const dialog = container.querySelector('[role="alertdialog"]')
+ const match = [...(dialog?.querySelectorAll('button') ?? [])].find(
+ (candidate) => candidate.textContent === label
+ )
+ if (!match) throw new Error(`No confirmation button labelled "${label}"`)
+ await act(async () => match.click())
+}
+
+async function changePassword(value: string) {
+ const input = container.querySelector('[aria-label="Password"]')
+ if (!input) throw new Error('Password input was not rendered')
+ const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (!valueSetter) throw new Error('Password input has no value setter')
+ await act(async () => {
+ valueSetter.call(input, value)
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+}
+
+async function changeAllowedEmails(value: string) {
+ const input = container.querySelector('[aria-label="Allowed emails"]')
+ if (!input) throw new Error('Allowed emails input was not rendered')
+ const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (!valueSetter) throw new Error('Allowed emails input has no value setter')
+ await act(async () => {
+ valueSetter.call(input, value)
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+}
+
+describe('ShareModal', () => {
+ beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ onOpenChange = vi.fn()
+ fileShareState.current = null
+ fileShareQueryState.isFetchedAfterMount = true
+ fileShareQueryState.isError = false
+ mutationState.isPending = false
+ permissionConfigState.current = {
+ allowedFileShareAuthTypes: null,
+ disablePublicFileSharing: false,
+ }
+ mockMutate.mockImplementation(
+ (variables: MockMutationVariables, callbacks?: MockMutationCallbacks) => {
+ const existing = fileShareState.current
+ const authType = variables.authType ?? existing?.authType ?? 'public'
+ fileShareState.current = {
+ id: existing?.id ?? 'share-1',
+ token: existing?.token ?? 'persisted-token',
+ url: existing?.url ?? SHARE_URL,
+ isActive: variables.isActive,
+ resourceType: 'file',
+ resourceId: 'file-1',
+ authType,
+ hasPassword: Boolean(variables.password) || existing?.hasPassword === true,
+ allowedEmails: variables.allowedEmails ?? existing?.allowedEmails ?? [],
+ }
+ callbacks?.onSuccess?.()
+ }
+ )
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.clearAllMocks()
+ })
+
+ it('shares without closing, then exposes the durable link and unshare action', async () => {
+ await renderModal()
+
+ expect(container.querySelector('[data-testid="modal-body"]')).not.toHaveClass('h-[280px]')
+ expect(container.querySelector('[data-testid="modal-body"]')).not.toHaveClass('flex-none')
+ expect(button('Public')).toHaveAttribute('aria-checked', 'true')
+ expect(queryButton('Copy link')).toBeUndefined()
+ expect(button('Share')).toBeEnabled()
+ expect(button('Share')).toHaveAttribute('data-variant', 'primary')
+
+ await click('Share')
+
+ expect(mockMutate).toHaveBeenLastCalledWith(
+ {
+ workspaceId: 'workspace-1',
+ fileId: 'file-1',
+ token: 'pending-token-1234567890',
+ isActive: true,
+ authType: 'public',
+ },
+ expect.objectContaining({ onSuccess: expect.any(Function) })
+ )
+ expect(onOpenChange).not.toHaveBeenCalled()
+ expect(mockToastSuccess).toHaveBeenLastCalledWith('File shared')
+
+ await renderModal()
+
+ expect(button('Unshare')).toBeEnabled()
+ expect(button('Unshare')).toHaveAttribute('data-variant', 'destructive')
+ expect(button('Copy link').querySelector('[data-testid="link-icon"]')).not.toBeNull()
+
+ await click('Copy link')
+ expect(mockCopy).toHaveBeenCalledWith(SHARE_URL)
+
+ mockMutate.mockClear()
+ await click('Unshare')
+ expect(mockMutate).not.toHaveBeenCalled()
+ expect(button('Unsharing...')).toHaveAttribute('data-variant', 'destructive')
+ const confirmDialog = container.querySelector('[role="alertdialog"]')
+ expect(confirmDialog).not.toBeNull()
+ expect(confirmDialog).toHaveTextContent('Unshare file?')
+
+ await clickConfirmation('Unshare')
+
+ expect(mockMutate).toHaveBeenLastCalledWith(
+ expect.objectContaining({ isActive: false }),
+ expect.objectContaining({ onSuccess: expect.any(Function) })
+ )
+ expect(onOpenChange).not.toHaveBeenCalled()
+ expect(mockToastSuccess).toHaveBeenLastCalledWith('File unshared')
+
+ await renderModal()
+ expect(button('Share')).toBeEnabled()
+ expect(queryButton('Copy link')).toBeUndefined()
+ })
+
+ it('keeps the link visible and changes Unshare to Update while editing the publish mode', async () => {
+ fileShareState.current = createShare()
+ await renderModal()
+
+ expect(button('Unshare')).toBeEnabled()
+ await click('Password')
+
+ expect(button('Copy link')).toBeEnabled()
+ expect(button('Update')).toBeDisabled()
+ expect(button('Update')).toHaveAttribute('data-variant', 'primary')
+
+ await changePassword('correct horse battery staple')
+ expect(button('Update')).toBeEnabled()
+
+ await click('Update')
+
+ expect(mockMutate).toHaveBeenLastCalledWith(
+ {
+ workspaceId: 'workspace-1',
+ fileId: 'file-1',
+ token: undefined,
+ isActive: true,
+ authType: 'password',
+ password: 'correct horse battery staple',
+ },
+ expect.objectContaining({ onSuccess: expect.any(Function) })
+ )
+ expect(onOpenChange).not.toHaveBeenCalled()
+ expect(mockToastSuccess).toHaveBeenLastCalledWith('Sharing updated')
+ })
+
+ it.each([
+ {
+ description: 'null',
+ initialShare: null,
+ pendingAction: 'Share',
+ expectedHint: 'Share to make this file accessible to anyone with the link.',
+ },
+ {
+ description: 'stale',
+ initialShare: createShare(),
+ pendingAction: 'Unshare',
+ expectedHint: 'Anyone with the link can view and download this file.',
+ },
+ ])(
+ 'waits for the authoritative share read when initial display data is $description',
+ async ({ initialShare, pendingAction, expectedHint }) => {
+ fileShareQueryState.isFetchedAfterMount = false
+ await renderModal(initialShare)
+
+ expect(button(pendingAction)).toBeDisabled()
+ expect(container).toHaveTextContent(expectedHint)
+ expect(container).not.toHaveTextContent('Loading the current sharing settings...')
+
+ fileShareState.current = createShare({
+ authType: 'password',
+ hasPassword: true,
+ })
+ fileShareQueryState.isFetchedAfterMount = true
+ await renderModal(initialShare)
+
+ expect(button('Password')).toHaveAttribute('aria-checked', 'true')
+ expect(button('Unshare')).toBeEnabled()
+ }
+ )
+
+ it.each([
+ { mode: 'Email' as const, authType: 'email' as const, entry: 'person@example.com' },
+ { mode: 'SSO' as const, authType: 'sso' as const, entry: 'example.com' },
+ ])('requires an allow-list before sharing in $mode mode', async ({ mode, authType, entry }) => {
+ await renderModal()
+ await click(mode)
+
+ expect(button('Share')).toBeDisabled()
+
+ await changeAllowedEmails(entry)
+ expect(button('Share')).toBeEnabled()
+
+ await click('Share')
+
+ expect(mockMutate).toHaveBeenLastCalledWith(
+ {
+ workspaceId: 'workspace-1',
+ fileId: 'file-1',
+ token: 'pending-token-1234567890',
+ isActive: true,
+ authType,
+ allowedEmails: [entry],
+ },
+ expect.objectContaining({ onSuccess: expect.any(Function) })
+ )
+ expect(onOpenChange).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ { mode: 'Password' as const, value: 'correct horse battery staple' },
+ { mode: 'Email' as const, value: 'person@example.com' },
+ ])('locks access edits and dismissal while a $mode share is pending', async ({ mode, value }) => {
+ await renderModal()
+ await click(mode)
+ if (mode === 'Password') {
+ await changePassword(value)
+ } else {
+ await changeAllowedEmails(value)
+ }
+
+ let finishMutation: (() => void) | undefined
+ mockMutate.mockImplementationOnce(
+ (_variables: MockMutationVariables, callbacks?: MockMutationCallbacks) => {
+ mutationState.isPending = true
+ finishMutation = callbacks?.onSuccess
+ }
+ )
+
+ await click('Share')
+ await renderModal()
+
+ expect(container.querySelector('[role="dialog"]')).toHaveAttribute(
+ 'data-dismiss-disabled',
+ 'true'
+ )
+ expect(button('Public')).toBeDisabled()
+ expect(button('Password')).toBeDisabled()
+ expect(button('Email')).toBeDisabled()
+ expect(button('SSO')).toBeDisabled()
+ expect(button('Sharing...')).toBeDisabled()
+
+ const editor = container.querySelector(
+ mode === 'Password' ? '[aria-label="Password"]' : '[aria-label="Allowed emails"]'
+ )
+ expect(editor).toBeDisabled()
+
+ await act(async () => {
+ mutationState.isPending = false
+ finishMutation?.()
+ })
+ })
+
+ it('blocks a new share when public file sharing is disabled', async () => {
+ permissionConfigState.current = {
+ allowedFileShareAuthTypes: null,
+ disablePublicFileSharing: true,
+ }
+
+ await renderModal()
+
+ expect(button('Share')).toBeDisabled()
+ })
+
+ it('blocks sharing an inactive saved mode that is no longer allowed', async () => {
+ permissionConfigState.current = {
+ allowedFileShareAuthTypes: ['public'],
+ disablePublicFileSharing: false,
+ }
+ fileShareState.current = createShare({
+ isActive: false,
+ authType: 'email',
+ allowedEmails: ['person@example.com'],
+ })
+
+ await renderModal()
+
+ expect(button('Email')).toHaveAttribute('aria-checked', 'true')
+ expect(button('Share')).toBeDisabled()
+ })
+
+ it('allows unsharing an active saved mode that is no longer allowed', async () => {
+ permissionConfigState.current = {
+ allowedFileShareAuthTypes: ['public'],
+ disablePublicFileSharing: false,
+ }
+ fileShareState.current = createShare({
+ authType: 'email',
+ allowedEmails: ['person@example.com'],
+ })
+
+ await renderModal()
+
+ expect(button('Email')).toHaveAttribute('aria-checked', 'true')
+ expect(button('Unshare')).toBeEnabled()
+
+ await click('Unshare')
+ await clickConfirmation('Unshare')
+ expect(mockMutate).toHaveBeenLastCalledWith(
+ expect.objectContaining({ isActive: false }),
+ expect.objectContaining({ onSuccess: expect.any(Function) })
+ )
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx
index 073ce695588..26d9016c13d 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx
@@ -4,18 +4,21 @@ import { useState } from 'react'
import {
ButtonGroup,
ButtonGroupItem,
+ Chip,
+ ChipConfirmModal,
ChipModal,
ChipModalBody,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
+ toast,
+ useCopyToClipboard,
} from '@sim/emcn'
-import { Send } from '@sim/emcn/icons'
+import { Check, Link, Send } from '@sim/emcn/icons'
import { generateShortId } from '@sim/utils/id'
import { GeneratedPasswordInput } from '@/components/ui'
import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares'
import { isSsoEnabled } from '@/lib/core/config/env-flags'
-import { getBaseUrl } from '@/lib/core/utils/urls'
import { validateAllowlistEntry } from '@/lib/messaging/email/validation'
import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares'
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -30,22 +33,30 @@ interface ShareModalProps {
initialShare?: ShareRecord | null
}
-type AccessMode = 'private' | ShareAuthType
-
-const ACCESS_LABELS: Record = {
- private: 'Private',
+const ACCESS_LABELS: Record = {
public: 'Public',
password: 'Password',
email: 'Email',
sso: 'SSO',
}
+const PRIMARY_ACTION_LABELS = {
+ share: { idle: 'Share', pending: 'Sharing...' },
+ update: { idle: 'Update', pending: 'Updating...' },
+ unshare: { idle: 'Unshare', pending: 'Unsharing...' },
+} as const
+
+const PRIMARY_ACTION_SUCCESS_MESSAGES = {
+ share: 'File shared',
+ update: 'Sharing updated',
+ unshare: 'File unshared',
+} as const
+
/** Stable identity so the emails field's reconcile effect no-ops while unset. */
const EMPTY_EMAILS: string[] = []
-function savedMode(share: ShareRecord | null): AccessMode {
- if (!share?.isActive) return 'private'
- return share.authType
+function savedMode(share: ShareRecord | null): ShareAuthType {
+ return share?.authType ?? 'public'
}
export function ShareModal({
@@ -56,32 +67,26 @@ export function ShareModal({
fileName,
initialShare,
}: ShareModalProps) {
- const { data: share, isFetched } = useFileShare(workspaceId, fileId, { enabled: open })
+ const {
+ data: share,
+ isError: isShareError,
+ isFetchedAfterMount,
+ } = useFileShare(workspaceId, fileId, { enabled: open })
const { config: permissionConfig } = usePermissionConfig()
const upsertShare = useUpsertFileShare()
+ const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
- const saved = share ?? initialShare ?? null
+ const shareReadReady = isFetchedAfterMount && !isShareError
+ const saved = shareReadReady ? (share ?? null) : (share ?? initialShare ?? null)
const savedAccessMode = savedMode(saved)
- // Reserve a token on open (one per mount — the modal remounts each open) so the
- // link can be shown and copied before the first save; it's persisted on save.
- // Only used once we've confirmed no share row exists yet, so a copied link
- // always matches what gets stored.
- const [pendingToken] = useState(() => generateShortId())
- const noExistingShare = isFetched && !share && !initialShare
- const shareUrl = saved?.url ?? (noExistingShare ? `${getBaseUrl()}/f/${pendingToken}` : null)
-
- // `null` until the user changes the selector, so the control always reflects the
- // authoritative saved state (which may resolve after mount via useFileShare).
- const [draftMode, setDraftMode] = useState(null)
+ const [draftMode, setDraftMode] = useState(null)
const [draftPassword, setDraftPassword] = useState('')
const [draftEmails, setDraftEmails] = useState(null)
+ const [unshareConfirmOpen, setUnshareConfirmOpen] = useState(false)
const effectiveMode = draftMode ?? savedAccessMode
- const effectiveActive = effectiveMode !== 'private'
const effectiveEmails = draftEmails ?? saved?.allowedEmails ?? EMPTY_EMAILS
- // Org access-control may restrict which auth modes are allowed (`null` = all).
- // The route is the source of truth; this just hides disallowed options.
const allowedAuthTypes = permissionConfig.allowedFileShareAuthTypes
const isAuthTypeAllowed = (mode: ShareAuthType) =>
allowedAuthTypes === null || allowedAuthTypes.includes(mode)
@@ -93,22 +98,16 @@ export function ShareModal({
'email',
...(ssoEnabled ? (['sso'] as const) : []),
]
- // Keep the saved mode visible even if newly disallowed, so the current state shows.
- const accessModes: AccessMode[] = [
- 'private',
- ...candidateAuthTypes.filter((mode) => isAuthTypeAllowed(mode) || mode === savedAccessMode),
- ]
+ const accessModes = candidateAuthTypes.filter(
+ (mode) => isAuthTypeAllowed(mode) || mode === savedAccessMode
+ )
- // The selected mode is blocked when org policy disables public sharing entirely
- // (enabling a new share) or when the chosen auth mode isn't allowed.
- const modeDisallowed = effectiveMode !== 'private' && !isAuthTypeAllowed(effectiveMode)
+ const modeDisallowed = !isAuthTypeAllowed(effectiveMode)
const enableBlockedByPolicy =
(permissionConfig.disablePublicFileSharing && !saved?.isActive) || modeDisallowed
- // A password share needs a secret: either one already stored or a freshly typed one.
const passwordMissing =
effectiveMode === 'password' && !saved?.hasPassword && draftPassword.trim().length === 0
- // Email/SSO shares need at least one allowed email/domain.
const emailsMissing =
(effectiveMode === 'email' || effectiveMode === 'sso') && effectiveEmails.length === 0
@@ -119,6 +118,11 @@ export function ShareModal({
(draftMode !== null && draftMode !== savedAccessMode) ||
(effectiveMode === 'password' && draftPassword.length > 0) ||
((effectiveMode === 'email' || effectiveMode === 'sso') && emailsDirty)
+ const primaryAction = saved?.isActive ? (isDirty ? 'update' : 'unshare') : 'share'
+ const isUnshareAction = primaryAction === 'unshare'
+ const primaryActionPending = upsertShare.isPending || (isUnshareAction && unshareConfirmOpen)
+ const primaryLabel =
+ PRIMARY_ACTION_LABELS[primaryAction][primaryActionPending ? 'pending' : 'idle']
const resetDraft = () => {
setDraftMode(null)
@@ -127,122 +131,163 @@ export function ShareModal({
}
const handleClose = () => {
+ setUnshareConfirmOpen(false)
resetDraft()
onOpenChange(false)
}
- const handleSave = () => {
- // Persist the reserved token only when creating the row; existing shares keep
- // their own token (the server ignores this on conflict).
- const base = { workspaceId, fileId, token: saved ? undefined : pendingToken }
- const vars =
- effectiveMode === 'private'
- ? { ...base, isActive: false as const }
- : effectiveMode === 'password'
+ const submitPrimaryAction = () => {
+ if (!shareReadReady || upsertShare.isPending) return
+
+ const base = { workspaceId, fileId, token: saved ? undefined : generateShortId() }
+ const vars = isUnshareAction
+ ? { ...base, isActive: false as const }
+ : effectiveMode === 'password'
+ ? {
+ ...base,
+ isActive: true as const,
+ authType: 'password' as const,
+ password: draftPassword.trim() || undefined,
+ }
+ : effectiveMode === 'email' || effectiveMode === 'sso'
? {
...base,
isActive: true as const,
- authType: 'password' as const,
- password: draftPassword.trim() || undefined,
+ authType: effectiveMode,
+ allowedEmails: effectiveEmails,
}
- : effectiveMode === 'email' || effectiveMode === 'sso'
- ? {
- ...base,
- isActive: true as const,
- authType: effectiveMode,
- allowedEmails: effectiveEmails,
- }
- : { ...base, isActive: true as const, authType: 'public' as const }
+ : { ...base, isActive: true as const, authType: 'public' as const }
upsertShare.mutate(vars, {
onSuccess: () => {
+ toast.success(PRIMARY_ACTION_SUCCESS_MESSAGES[primaryAction])
+ setUnshareConfirmOpen(false)
resetDraft()
- onOpenChange(false)
},
})
}
+ const handlePrimaryAction = () => {
+ if (isUnshareAction) {
+ setUnshareConfirmOpen(true)
+ return
+ }
+ submitPrimaryAction()
+ }
+
const accessHint = (() => {
+ if (isShareError) return 'Unable to load the current sharing settings. Close and try again.'
if (modeDisallowed) return 'This sharing method is disabled by an administrator.'
if (enableBlockedByPolicy)
return 'Public sharing is disabled for this workspace by an administrator.'
- if (effectiveMode === 'private') return 'Only workspace members can access this file.'
if (effectiveMode === 'password')
return 'Anyone with the link and the password can view and download this file.'
if (effectiveMode === 'email')
return 'Only allowed emails can access this file after a one-time code.'
if (effectiveMode === 'sso')
return 'Only allowed emails signed in via SSO can access this file.'
- return isDirty
- ? 'Save to make this file accessible to anyone with the link.'
- : 'Anyone with the link can view and download this file.'
+ return saved?.isActive && !isDirty
+ ? 'Anyone with the link can view and download this file.'
+ : `${saved?.isActive ? 'Update' : 'Share'} to make this file accessible to anyone with the link.`
})()
return (
-
-
- Share file
-
-
-
- setDraftMode(value as AccessMode)}
- aria-label='File access'
- >
- {accessModes.map((mode) => (
-
- {ACCESS_LABELS[mode]}
-
- ))}
-
-
- {effectiveMode === 'password' ? (
-
-
+ <>
+
+
+ Share file
+
+
+
+ setDraftMode(value as ShareAuthType)}
+ aria-label='File access'
+ disabled={upsertShare.isPending}
+ >
+ {accessModes.map((mode) => (
+
+ {ACCESS_LABELS[mode]}
+
+ ))}
+
- ) : null}
- {effectiveMode === 'email' || effectiveMode === 'sso' ? (
-
- ) : null}
- {effectiveMode !== 'private' && shareUrl ? (
-
- ) : null}
-
-
+
+
+ ) : null}
+ {effectiveMode === 'email' || effectiveMode === 'sso' ? (
+
+ ) : null}
+
+ copy(saved.url)}>
+ {copied ? 'Copied!' : 'Copy link'}
+
+ ),
+ },
+ ]
+ : undefined
+ }
+ primaryAction={{
+ label: primaryLabel,
+ onClick: handlePrimaryAction,
+ variant: isUnshareAction ? 'destructive' : 'primary',
+ disabled:
+ upsertShare.isPending ||
+ !shareReadReady ||
+ (!isUnshareAction && (passwordMissing || emailsMissing || enableBlockedByPolicy)),
+ }}
+ />
+
+
-
+ >
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
index 71800c82e2c..c7f64d21a77 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
@@ -16,8 +16,9 @@ import {
Trash,
toast,
Upload,
+ useCopyToClipboard,
} from '@sim/emcn'
-import { Download, Send } from '@sim/emcn/icons'
+import { Check, Download, Link, Send } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { useParams, useRouter } from 'next/navigation'
@@ -128,7 +129,6 @@ import {
} from '@/app/workspace/[workspaceId]/files/untitled-title'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
-import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items'
import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace'
import {
@@ -147,6 +147,7 @@ import {
useUploadWorkspaceFile,
useWorkspaceFiles,
} from '@/hooks/queries/workspace-files'
+import { useContextMenu } from '@/hooks/use-context-menu'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
import { useInlineRename } from '@/hooks/use-inline-rename'
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -273,6 +274,7 @@ export function Files() {
const userPermissions = useUserPermissionsContext()
const canEdit = userPermissions.canEdit === true
const { config: permissionConfig } = usePermissionConfig()
+ const { copied: copiedFileLink, copy: copyFileLink } = useCopyToClipboard({ resetMs: 1500 })
// Joined for the live file tree: a `workspace-files-changed` broadcast invalidates the
// browser. "Who's in this file" comes from the file-doc room (see FileDocRoomProvider),
@@ -1397,6 +1399,19 @@ export function Files() {
closeContextMenu()
}, [selectedRowIds, handleBulkDownload, closeContextMenu, downloadArchive, handleDownload])
+ const handleContextMenuCopyLink = useCallback(() => {
+ const item = contextMenuItemRef.current
+ if (item?.kind === 'file') {
+ void copyFileLink(
+ `${window.location.origin}/workspace/${workspaceId}/files/${item.file.id}`
+ ).then((copied) => {
+ if (copied) toast.success('Copied link to clipboard')
+ else toast.error('Failed to copy link')
+ })
+ }
+ closeContextMenu()
+ }, [closeContextMenu, copyFileLink, workspaceId])
+
const handleContextMenuRename = useCallback(() => {
const item = contextMenuItemRef.current
if (item?.kind === 'file') listRename.startRename(item.file.id, item.file.name)
@@ -1613,7 +1628,6 @@ export function Files() {
const isSimPage = selectedFile.type === SIM_PAGE_CONTENT_TYPE
const hasSplitView = canEditText && canPreview && !isInlineMarkdown && !isSimPage
const showPreviewToggle = canPreview && !isInlineMarkdown && !isSimPage
-
const nextModeLabel =
previewMode === 'editor' ? 'Split' : previewMode === 'split' ? 'Preview' : 'Edit'
const nextModeIcon =
@@ -1637,6 +1651,15 @@ export function Files() {
},
]
: []),
+ {
+ id: 'copy-link',
+ text: copiedFileLink ? 'Copied!' : 'Copy Link',
+ icon: copiedFileLink ? Check : Link,
+ onSelect: () =>
+ void copyFileLink(
+ `${window.location.origin}/workspace/${workspaceId}/files/${selectedFile.id}`
+ ),
+ },
{
text: 'Download',
icon: Download,
@@ -1665,6 +1688,9 @@ export function Files() {
handleCyclePreviewMode,
handleTogglePreview,
handleDownloadSelected,
+ copiedFileLink,
+ copyFileLink,
+ workspaceId,
handleShareSelected,
handleDeleteSelected,
])
@@ -2244,6 +2270,7 @@ export function Files() {
position={contextMenuPosition}
onClose={closeContextMenu}
onOpen={handleContextMenuOpen}
+ onCopyLink={contextMenuItem?.kind === 'file' ? handleContextMenuCopyLink : undefined}
onDownload={handleContextMenuDownload}
onRename={handleContextMenuRename}
onDelete={handleContextMenuDelete}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/index.ts
index 1d1a257d880..7a83ebc996e 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/index.ts
@@ -1,5 +1,6 @@
export {
assistantMessageHasRenderableContent,
+ getOrchestratorMessageText,
MessageContent,
} from './message-content'
export type { MessagePhase } from './utils'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts
index d50471c4337..76b2976c67c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts
@@ -27,6 +27,7 @@ import type { ContentBlock } from '../../types'
import {
assistantMessageHasVisibleExecutingTool,
deriveThinkingLabel,
+ getOrchestratorMessageText,
parseBlocks,
shouldSmoothTextSegment,
} from './message-content'
@@ -100,6 +101,66 @@ function toolEnvelope(
} as PersistedStreamEventEnvelope
}
+describe('getOrchestratorMessageText', () => {
+ it('copies only orchestrator text from span-based messages', () => {
+ const blocks: ContentBlock[] = [
+ subagentStart('research', 'span-visible', 'main'),
+ {
+ type: 'subagent_text',
+ content: 'Visible research. ',
+ spanId: 'span-visible',
+ timestamp: 2,
+ },
+ {
+ type: 'subagent_text',
+ content: 'Hidden orphan. ',
+ spanId: 'span-orphan',
+ timestamp: 3,
+ },
+ mainText('Main answer.'),
+ ]
+
+ expect(getOrchestratorMessageText(blocks, 'Fallback.')).toBe('Main answer.')
+ })
+
+ it('copies only orchestrator text from legacy messages', () => {
+ const blocks: ContentBlock[] = [
+ { type: 'subagent_text', content: 'Hidden orphan. ', timestamp: 1 },
+ {
+ type: 'subagent',
+ content: 'research',
+ parentToolCallId: 'dispatch-visible',
+ timestamp: 2,
+ },
+ {
+ type: 'subagent_text',
+ content: 'Visible research. ',
+ parentToolCallId: 'dispatch-visible',
+ timestamp: 3,
+ },
+ mainText('Main answer.'),
+ ]
+
+ expect(getOrchestratorMessageText(blocks, 'Fallback.')).toBe('Main answer.')
+ })
+
+ it('separates orchestrator text blocks around excluded subagent output', () => {
+ const blocks: ContentBlock[] = [
+ mainText('Starting answer.'),
+ subagentStart('research', 'span-visible', 'main'),
+ {
+ type: 'subagent_text',
+ content: 'Visible research.',
+ spanId: 'span-visible',
+ timestamp: 2,
+ },
+ mainText('Main answer.'),
+ ]
+
+ expect(getOrchestratorMessageText(blocks, 'Fallback.')).toBe('Starting answer.\n\nMain answer.')
+ })
+})
+
describe('parseBlocks span-identity tree', () => {
it('refines a completed credential rename with its previous and new names', () => {
const segments = parseBlocks([
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
index e16282ea66b..5a13215a565 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
@@ -492,6 +492,23 @@ export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
return parseBlocksLegacy(blocks)
}
+function joinRenderableText(parts: string[]): string {
+ return parts.filter(Boolean).join('\n\n')
+}
+
+/** Returns only top-level orchestrator text, excluding agent groups and other UI segments. */
+export function getOrchestratorMessageText(
+ blocks: ContentBlock[],
+ fallbackContent: string
+): string {
+ const parsed = blocks.length > 0 ? parseBlocks(blocks) : []
+ if (parsed.length === 0) return fallbackContent
+
+ return joinRenderableText(
+ parsed.map((segment) => (segment.type === 'text' ? segment.content : ''))
+ )
+}
+
function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
const segments: MessageSegment[] = []
const groupsByKey = new Map()
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts
new file mode 100644
index 00000000000..1bdc3dd78a5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
+import {
+ prepareCopyableMarkdown,
+ toCopyableMarkdown,
+} from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
+import { parseChipLinks } from '@/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec'
+
+const WORKSPACE_FILES: WorkspaceFileRecord[] = [
+ {
+ id: 'file_bell',
+ workspaceId: 'workspace-1',
+ name: 'The Bell at Low Tide.md',
+ key: 'workspace/workspace-1/file_bell',
+ path: '/api/files/view/file_bell',
+ size: 0,
+ type: 'text/markdown',
+ uploadedBy: 'user-1',
+ uploadedAt: new Date(0),
+ updatedAt: new Date(0),
+ },
+]
+
+describe('toCopyableMarkdown', () => {
+ it('preserves message Markdown, including fenced code and its language', () => {
+ const message = [
+ '# Elevator diagnosis',
+ '',
+ 'The bug is in `dispatch_legacy.py`:',
+ '',
+ '```python',
+ 'def next_stop(requests, current):',
+ ' ranked = sorted(requests)',
+ ' return ranked[1:]',
+ '```',
+ '',
+ '**Result:** the closest request *was not* always selected.',
+ ].join('\n')
+
+ expect(toCopyableMarkdown(message)).toBe(message)
+ })
+
+ it('removes internal structured tags without flattening surrounding Markdown', () => {
+ const message = [
+ 'Before **formatted text**.',
+ '{"type":"service_account","provider":"gmail"}',
+ 'After [a link](https://example.com).',
+ ].join('\n')
+
+ expect(toCopyableMarkdown(message)).toBe(
+ ['Before **formatted text**.', '', 'After [a link](https://example.com).'].join('\n')
+ )
+ })
+
+ it('preserves tag-shaped text that the chat renders literally', () => {
+ const message = [
+ 'Document `example`.',
+ '',
+ '```html',
+ 'example',
+ 'example',
+ '```',
+ ].join('\n')
+
+ expect(toCopyableMarkdown(message)).toBe(message)
+ })
+
+ it('copies workspace resources as portable Markdown links with real ids', () => {
+ const message = [
+ 'Read',
+ '{"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}',
+ 'and',
+ `${JSON.stringify({
+ type: 'table',
+ id: 'tbl_f26af6dae98d4222b014b250494d00fb',
+ title: 'Checked_[rare]\\portal',
+ })}.`,
+ ].join('')
+
+ const markdown = toCopyableMarkdown(message, WORKSPACE_FILES)
+
+ expect(markdown).toBe(
+ 'Read [The Bell at Low Tide.md](sim:file/file_bell) and [Checked_\\[rare\\]\\\\portal](sim:table/tbl_f26af6dae98d4222b014b250494d00fb).'
+ )
+ expect(parseChipLinks(markdown)).toEqual([
+ {
+ kind: 'file',
+ id: 'file_bell',
+ label: 'The Bell at Low Tide.md',
+ start: 5,
+ end: 50,
+ },
+ {
+ kind: 'table',
+ id: 'tbl_f26af6dae98d4222b014b250494d00fb',
+ label: 'Checked_[rare]\\portal',
+ start: 55,
+ end: 129,
+ },
+ ])
+ })
+
+ it('uses resolved file metadata for a resource without a title', () => {
+ const message =
+ 'Read {"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md"}.'
+
+ expect(toCopyableMarkdown(message, WORKSPACE_FILES)).toBe(
+ 'Read [The Bell at Low Tide.md](sim:file/file_bell).'
+ )
+ })
+
+ it('refreshes missing file metadata before producing copyable Markdown', async () => {
+ const message =
+ 'Read {"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}.'
+ const refreshWorkspaceFiles = vi.fn().mockResolvedValue(WORKSPACE_FILES)
+
+ const content = prepareCopyableMarkdown(message, [], refreshWorkspaceFiles)
+ expect(content).not.toBeTypeOf('string')
+ if (typeof content === 'string') throw new Error('Expected deferred clipboard content')
+ expect(content.fallback).toBe('Read The Bell at Low Tide.md.')
+ expect(parseChipLinks(content.fallback)).toEqual([])
+ await expect(content.prepare()).resolves.toBe(
+ 'Read [The Bell at Low Tide.md](sim:file/file_bell).'
+ )
+ expect(refreshWorkspaceFiles).toHaveBeenCalledOnce()
+ })
+
+ it('copies unresolved file references as plain text', () => {
+ const message =
+ 'Read {"type":"file","path":"files/Q1 plan).md","title":"Q1 plan).md"}.'
+
+ const markdown = toCopyableMarkdown(message)
+
+ expect(markdown).toBe('Read Q1 plan).md.')
+ expect(parseChipLinks(markdown)).toEqual([])
+ })
+
+ it('keeps the plain-text fallback when refreshing file metadata fails', async () => {
+ const message =
+ 'Read {"type":"file","path":"files/notes.md","title":"notes.md"}.'
+ const refreshWorkspaceFiles = vi.fn().mockRejectedValue(new Error('Refresh failed'))
+
+ const content = prepareCopyableMarkdown(message, [], refreshWorkspaceFiles)
+
+ expect(content).not.toBeTypeOf('string')
+ if (typeof content === 'string') throw new Error('Expected deferred clipboard content')
+ expect(content.fallback).toBe('Read notes.md.')
+ await expect(content.prepare()).resolves.toBe('Read notes.md.')
+ expect(refreshWorkspaceFiles).toHaveBeenCalledOnce()
+ })
+
+ it('does not refresh metadata when all workspace resources already resolve', () => {
+ const message =
+ 'Read {"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}.'
+ const refreshWorkspaceFiles = vi.fn()
+
+ expect(prepareCopyableMarkdown(message, WORKSPACE_FILES, refreshWorkspaceFiles)).toBe(
+ 'Read [The Bell at Low Tide.md](sim:file/file_bell).'
+ )
+ expect(refreshWorkspaceFiles).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts
new file mode 100644
index 00000000000..0a27f697f6a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts
@@ -0,0 +1,101 @@
+import type { ClipboardContent } from '@sim/emcn'
+import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
+import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize'
+import {
+ type ContentSegment,
+ parseSpecialTags,
+ type WorkspaceResourceTagData,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+import { serializePortableChipLink } from '@/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec'
+import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
+
+interface CopyableMarkdownResult {
+ markdown: string
+ hasUnresolvedFile: boolean
+}
+
+function workspaceResourceLabel(data: WorkspaceResourceTagData): string {
+ if (data.title) return data.title
+ return data.type === 'file' ? (data.path ?? data.id ?? '') : (data.id ?? '')
+}
+
+function appendInlineReferenceMarkdown(
+ currentMarkdown: string,
+ referenceMarkdown: string,
+ nextSegment?: ContentSegment
+): string {
+ const followingText =
+ nextSegment?.type === 'text'
+ ? nextSegment.content
+ : nextSegment?.type === 'workspace_resource'
+ ? nextSegment.data.title || nextSegment.data.id || ''
+ : ''
+ const leadingSpace = /[A-Za-z0-9_)]$/.test(currentMarkdown) ? ' ' : ''
+ const trailingSpace =
+ /^[A-Za-z0-9_(]/.test(followingText) && !/\s$/.test(referenceMarkdown) ? ' ' : ''
+ return `${currentMarkdown}${leadingSpace}${referenceMarkdown}${trailingSpace}`
+}
+
+function portableWorkspaceResourceMarkdown(
+ data: WorkspaceResourceTagData,
+ workspaceFiles: readonly WorkspaceFileRecord[]
+): CopyableMarkdownResult {
+ const label = workspaceResourceLabel(data)
+ const resource = resolveWorkspaceResourceRef({ ...data, title: data.title ?? '' }, workspaceFiles)
+ return {
+ markdown: resource
+ ? serializePortableChipLink(data.type, resource.id, resource.title || label)
+ : label,
+ hasUnresolvedFile: data.type === 'file' && !resource,
+ }
+}
+
+function serializeCopyableMarkdown(
+ raw: string,
+ workspaceFiles: readonly WorkspaceFileRecord[] = []
+): CopyableMarkdownResult {
+ const displayContent = sanitizeChatDisplayContent(raw)
+ const { segments } = parseSpecialTags(displayContent, false)
+ let hasUnresolvedFile = false
+
+ const markdown = segments
+ .reduce((markdown, segment, index) => {
+ if (segment.type === 'text') return markdown + segment.content
+ if (segment.type === 'workspace_resource') {
+ const portable = portableWorkspaceResourceMarkdown(segment.data, workspaceFiles)
+ hasUnresolvedFile ||= portable.hasUnresolvedFile
+ return appendInlineReferenceMarkdown(markdown, portable.markdown, segments[index + 1])
+ }
+ return markdown
+ }, '')
+ .trim()
+
+ return { markdown, hasUnresolvedFile }
+}
+
+export function toCopyableMarkdown(
+ raw: string,
+ workspaceFiles: readonly WorkspaceFileRecord[] = []
+): string {
+ return serializeCopyableMarkdown(raw, workspaceFiles).markdown
+}
+
+export function prepareCopyableMarkdown(
+ raw: string,
+ workspaceFiles: readonly WorkspaceFileRecord[],
+ refreshWorkspaceFiles: () => Promise
+): ClipboardContent {
+ const initial = serializeCopyableMarkdown(raw, workspaceFiles)
+ if (!initial.hasUnresolvedFile) return initial.markdown
+
+ return {
+ fallback: initial.markdown,
+ prepare: async () => {
+ try {
+ return toCopyableMarkdown(raw, await refreshWorkspaceFiles())
+ } catch {
+ return initial.markdown
+ }
+ },
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
index 84025228547..0bd179c7d7f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
@@ -10,14 +10,17 @@ import {
useRef,
useState,
} from 'react'
-import { cn } from '@sim/emcn'
+import { type ClipboardContent, cn } from '@sim/emcn'
+import { useQueryClient } from '@tanstack/react-query'
import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual'
import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase'
+import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import {
assistantMessageHasRenderableContent,
+ getOrchestratorMessageText,
MessageContent,
type MessagePhase,
} from '@/app/workspace/[workspaceId]/home/components/message-content'
@@ -29,6 +32,7 @@ import {
parseLastCredentialTag,
parseLastQuestionTag,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+import { prepareCopyableMarkdown } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
import { nextSizerFloor } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor'
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
import {
@@ -46,12 +50,14 @@ import type {
WorkspaceResourceRef,
} from '@/app/workspace/[workspaceId]/home/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
+import { getWorkspaceFilesQueryOptions, workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import { useAutoScroll } from '@/hooks/use-auto-scroll'
import type { ChatContext } from '@/stores/panel'
import { MothershipChatSkeleton } from './components/mothership-chat-skeleton'
import { shouldShowAssistantMessageActions } from './message-actions-visibility'
interface MothershipChatProps {
+ workspaceId: string
messages: ChatMessage[]
isSending: boolean
isReconnecting?: boolean
@@ -148,6 +154,7 @@ const LAYOUT_STYLES = {
} as const
const EMPTY_BLOCKS: ContentBlock[] = []
+const EMPTY_WORKSPACE_FILES: readonly WorkspaceFileRecord[] = []
interface UserMessageRowProps {
content: string
@@ -185,6 +192,7 @@ const UserMessageRow = memo(function UserMessageRow({
interface AssistantMessageRowProps {
message: ChatMessage
+ prepareContentForCopy: (content: string) => ClipboardContent
isStreaming: boolean
isLast: boolean
precedingUserContent?: string
@@ -201,6 +209,7 @@ interface AssistantMessageRowProps {
const AssistantMessageRow = memo(function AssistantMessageRow({
message,
+ prepareContentForCopy,
isStreaming,
isLast,
precedingUserContent,
@@ -225,6 +234,10 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
onAnimatingChangeRef.current?.(phase !== 'settled')
}, [phase])
+ const getCopyContent = useCallback(
+ () => getOrchestratorMessageText(blocks, message.content),
+ [blocks, message.content]
+ )
const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '')
if (!hasRenderableAssistant && !trimmedContent && !isStreaming) {
return null
@@ -281,6 +294,9 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
actionsEligible ? (
(undefined)
const floorDrainRafRef = useRef(0)
+ const prepareContentForCopy = useCallback(
+ (content: string) =>
+ prepareCopyableMarkdown(
+ content,
+ queryClient.getQueryData(
+ workspaceFilesKeys.list(workspaceId)
+ ) ?? EMPTY_WORKSPACE_FILES,
+ () =>
+ queryClient.fetchQuery({
+ ...getWorkspaceFilesQueryOptions(workspaceId),
+ staleTime: 0,
+ })
+ ),
+ [queryClient, workspaceId]
+ )
useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), [])
/**
@@ -760,6 +793,7 @@ export function MothershipChat({
) : (
({
+ items: listIntegrationsByPopularity().map((integration) => ({
id: integration.blockType,
name: integration.name,
iconComponent: integration.icon,
@@ -265,12 +266,26 @@ export function useAvailableResources(
type: 'task' as const,
items: (tasks ?? []).map((t) => ({ id: t.id, name: t.name })),
},
+ /**
+ * The chip's `name` keeps the absolute timestamp because it is persisted
+ * with the chat, where "2m ago" would age into a lie; the row renders the
+ * relative form, which is what reads at a glance. `mentionFamily` is what
+ * lets `@logs` reach rows named after their workflow.
+ */
{
type: 'log' as const,
items: logs.map((log) => {
const workflowName = log.workflow?.name ?? log.workflowId ?? 'Unknown'
- const time = formatDate(log.createdAt).compact
- return { id: log.id, name: `${workflowName} · ${time}`, workflowName, time }
+ const when = formatDate(log.createdAt)
+ return {
+ id: log.id,
+ name: `${workflowName} · ${when.compact}`,
+ mentionFamily: getResourceConfig('log').label,
+ executionId: log.executionId ?? undefined,
+ workflowName,
+ time: when.relative,
+ status: log.status,
+ }
}),
},
]
@@ -364,7 +379,7 @@ export function ResourceFolderTreeItems({
node.kind === 'item' ? (
onSelect({ type, id: node.id, title: node.item.name })}
+ onClick={() => onSelect(resourceFromItem(type, node.item))}
>
{config.renderDropdownItem({ item: node.item })}
@@ -518,10 +533,7 @@ export function ResourceMenuSections({
if (!section && (type === 'browser' || type === 'terminal')) {
const item = items[0]
return (
- onSelect({ type, id: item.id, title: item.name })}
- >
+ onSelect(resourceFromItem(type, item))}>
{config.label}
@@ -546,7 +558,7 @@ export function ResourceMenuSections({
items.map((item) => (
onSelect({ type, id: item.id, title: item.name })}
+ onClick={() => onSelect(resourceFromItem(type, item))}
>
{config.renderDropdownItem({ item })}
@@ -642,7 +654,7 @@ export function AddResourceDropdown({
if (filtered.length > 0 && filtered[activeIndex]) {
e.preventDefault()
const { type, item } = filtered[activeIndex]
- select({ type, id: item.id, title: item.name })
+ select(resourceFromItem(type, item))
}
}
}
@@ -694,7 +706,7 @@ export function AddResourceDropdown({
key={`${type}:${item.id}`}
className={cn(index === activeIndex && 'bg-[var(--surface-hover)]')}
onMouseEnter={() => setActiveIndex(index)}
- onClick={() => select({ type, id: item.id, title: item.name })}
+ onClick={() => select(resourceFromItem(type, item))}
>
{config.renderDropdownItem({ item })}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts
index d419eca23be..02297bd7301 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts
@@ -6,3 +6,4 @@ export {
useAvailableResources,
useResourceTreeSections,
} from './add-resource-dropdown'
+export { resourceFromItem } from './resource-from-item'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item.test.ts
new file mode 100644
index 00000000000..6aecf863be6
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item.test.ts
@@ -0,0 +1,30 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { resourceFromItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item'
+
+describe('resourceFromItem', () => {
+ it('carries a log item execution id onto the resource', () => {
+ expect(
+ resourceFromItem('log', {
+ id: 'log-row-1',
+ name: 'Nightly sync · Aug 21 11:03:46',
+ executionId: 'exec-9',
+ })
+ ).toEqual({
+ type: 'log',
+ id: 'log-row-1',
+ title: 'Nightly sync · Aug 21 11:03:46',
+ executionId: 'exec-9',
+ })
+ })
+
+ it('builds the plain resource for a family that carries no extra identifier', () => {
+ expect(resourceFromItem('workflow', { id: 'wf-1', name: 'Nightly sync' })).toEqual({
+ type: 'workflow',
+ id: 'wf-1',
+ title: 'Nightly sync',
+ })
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item.ts
new file mode 100644
index 00000000000..e0aee706b30
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item.ts
@@ -0,0 +1,22 @@
+import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree'
+import type {
+ MothershipResource,
+ MothershipResourceType,
+} from '@/app/workspace/[workspaceId]/home/types'
+
+/**
+ * Builds the resource a picker row stands for.
+ *
+ * Every menu that selects a candidate goes through here so a family's extra
+ * identifier reaches the resource. Constructing the literal inline silently
+ * drops it — a log selected that way loses the execution id its chat context is
+ * addressed by. Only `executionId` is carried today; add a field here when
+ * another family needs one.
+ */
+export function resourceFromItem(
+ type: MothershipResourceType,
+ item: AvailableItem
+): MothershipResource {
+ const executionId = typeof item.executionId === 'string' ? item.executionId : undefined
+ return { type, id: item.id, title: item.name, ...(executionId ? { executionId } : {}) }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx
index a3067fb1e1e..e574d7be79f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads.tsx
@@ -163,7 +163,7 @@ export function BrowserDownloads({ scopeId, open, requestOpen, onClose }: Browse
)}
-
+
Downloads
{downloads.map((download) => {
const completed = download.state === 'completed'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx
index 839455c141e..2945162eac1 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx
@@ -207,33 +207,32 @@ export function BrowserTabStrip({
onTabContextMenu={openTabContextMenu}
onTabDragStart={startTabDrag}
onReorder={onReorderTab}
- >
- onSetTabPinned(contextTab.tabId, !contextTab.pinned) : undefined
- }
- onDuplicate={contextTab ? () => onDuplicateTab(contextTab.tabId) : undefined}
- // Pinned tabs are durable and deliberately have no close action.
- {...(contextTab && !contextTab.pinned
- ? { onCloseTab: () => onCloseTab(contextTab.tabId), showCloseTab: true }
- : {})}
- onDelete={() => {}}
- showPin={Boolean(contextTab)}
- isPinned={Boolean(contextTab?.pinned)}
- showRename={false}
- showDuplicate={Boolean(contextTab)}
- showDelete={false}
- />
-
+ overlays={
+ onSetTabPinned(contextTab.tabId, !contextTab.pinned) : undefined
+ }
+ onDuplicate={contextTab ? () => onDuplicateTab(contextTab.tabId) : undefined}
+ // Pinned tabs are durable and deliberately have no close action.
+ {...(contextTab && !contextTab.pinned
+ ? { onCloseTab: () => onCloseTab(contextTab.tabId), showCloseTab: true }
+ : {})}
+ onDelete={() => {}}
+ showPin={Boolean(contextTab)}
+ isPinned={Boolean(contextTab?.pinned)}
+ showRename={false}
+ showDuplicate={Boolean(contextTab)}
+ showDelete={false}
+ />
+ }
+ />
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.test.ts
index ea0a4eae4b9..58769251e92 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.test.ts
@@ -9,8 +9,45 @@ import {
terminalFontSizeForZoom,
terminalSelectionLabel,
terminalSelectionSnapshot,
+ terminalTooltip,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session'
+describe('terminal tab tooltips', () => {
+ it('summarizes a long compound heredoc command by its foreground program', () => {
+ const running = `mkdir -p ~/.doordash-bot/bin && cat > ~/.doordash-bot/bin/dd-cli-mock <<'EOF'
+#!/usr/bin/env node
+const carts = new Map()
+process.stdout.write(JSON.stringify([...carts]))
+EOF
+chmod +x ~/.doordash-bot/bin/dd-cli-mock && echo '--- smoke test ---' && ~/.doordash-bot/bin/dd-cli-mock submit mock_123`
+ const tooltip = terminalTooltip({
+ terminalId: 'terminal-1',
+ title: 'mkdir',
+ cwd: '/Users/emirkarabeg',
+ running,
+ interactive: false,
+ active: false,
+ })
+
+ expect(tooltip).toBe('/Users/emirkarabeg — dd-cli-mock')
+ expect(tooltip).not.toContain('const carts')
+ })
+
+ it('preserves the working-directory tooltip for idle terminals', () => {
+ const idleTab = {
+ terminalId: 'terminal-1',
+ title: 'sim',
+ cwd: '/Users/emirkarabeg/sim',
+ running: null,
+ interactive: false,
+ active: true,
+ }
+
+ expect(terminalTooltip(idleTab)).toBe('/Users/emirkarabeg/sim')
+ expect(terminalTooltip({ ...idleTab, cwd: null })).toBe('Terminal')
+ })
+})
+
describe('suspended terminal resource lifecycle', () => {
it('does not remove a resource when administrative suspension clears its PTYs', () => {
expect(shouldRemoveTerminalResource(0, true, true)).toBe(false)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx
index 39fefe98e06..cb124f6e479 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx
@@ -34,6 +34,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { type IBufferRange, Terminal } from '@xterm/xterm'
import { useTheme } from 'next-themes'
+import { useContextMenu } from '@/hooks/use-context-menu'
import '@xterm/xterm/css/xterm.css'
import {
describeRunningCommand,
@@ -73,7 +74,6 @@ import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/compo
import { TerminalContextMenu } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-context-menu'
import { TerminalTabIcon } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-tab-icon'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
-import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useDesktopPreferenceMutation } from '@/hooks/use-desktop-preference-mutation'
import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store'
import type { ChatContext, TerminalTextSelection } from '@/stores/panel'
@@ -114,10 +114,10 @@ function hideMountedMenuSurfaces(): void {
*/
const COMMAND_SETTLE_MS = 1_000
-/** Full working directory, plus whatever the shell is running in it. */
-function terminalTooltip(tab: TerminalTabState): string {
+/** Full working directory, plus a concise name for whatever the shell is running. */
+export function terminalTooltip(tab: TerminalTabState): string {
const where = tab.cwd ?? 'Terminal'
- return tab.running ? `${where} — ${tab.running}` : where
+ return tab.running ? `${where} — ${describeRunningCommand(tab.running)}` : where
}
function sameIds(a: ReadonlySet, b: ReadonlySet): boolean {
@@ -902,8 +902,8 @@ export function TerminalSession({ visible, scopeId }: TerminalSessionProps) {
id: tab.terminalId,
title: counts.get(label) === 1 ? label : `${label} ${occurrence}`,
// The label is a basename, and the tab may be running something it
- // is not naming yet, so hovering gives the whole picture: where the
- // shell is, and what it is doing there.
+ // is not naming yet, so hovering identifies the working directory and
+ // foreground program without exposing the literal command.
tooltip: terminalTooltip(tab),
icon: (
- handleDuplicate(contextTab.cwd) : undefined}
- onCloseOtherTabs={contextTab ? closeOtherTabs : undefined}
- onCloseTabsToRight={contextTab ? closeTabsToRight : undefined}
- disableCloseOtherTabs={tabs.length <= 1}
- disableCloseTabsToRight={contextIndex < 0 || contextIndex === tabs.length - 1}
- {...(contextTab
- ? { onCloseTab: () => handleClose(contextTab.terminalId), showCloseTab: true }
- : {})}
- onDelete={() => {}}
- showRename={false}
- showDuplicate={Boolean(contextTab)}
- showDelete={false}
- />
-
+ overlays={
+ handleDuplicate(contextTab.cwd) : undefined}
+ onCloseOtherTabs={contextTab ? closeOtherTabs : undefined}
+ onCloseTabsToRight={contextTab ? closeTabsToRight : undefined}
+ disableCloseOtherTabs={tabs.length <= 1}
+ disableCloseTabsToRight={contextIndex < 0 || contextIndex === tabs.length - 1}
+ {...(contextTab
+ ? { onCloseTab: () => handleClose(contextTab.terminalId), showCloseTab: true }
+ : {})}
+ onDelete={() => {}}
+ showRename={false}
+ showDuplicate={Boolean(contextTab)}
+ showDelete={false}
+ />
+ }
+ />
{tabs.map((tab) => (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/index.ts
index e8ae4e4ba60..73523336fc0 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/index.ts
@@ -3,6 +3,7 @@ export {
byResourceMenuOrder,
getResourceConfig,
invalidateResourceQueries,
+ MENTION_PREVIEW_DEFAULT_LIMIT,
RESOURCE_MENU_ORDER,
RESOURCE_REGISTRY,
} from './resource-registry'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
index 12af4420995..a4b32224b73 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
@@ -20,6 +20,7 @@ import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
+import { getDisplayStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils'
import { BrandIcon, type StyleableIcon } from '@/blocks/brand-icon'
import { logKeys } from '@/hooks/queries/logs'
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
@@ -40,6 +41,13 @@ export interface ResourceTypeConfig {
icon: ElementType
renderTabIcon: (resource: MothershipResource, className: string) => ReactNode
renderDropdownItem: (props: DropdownItemRenderProps) => ReactNode
+ /**
+ * How many of this family's candidates an unfiltered `@` list shows, overriding
+ * {@link MENTION_PREVIEW_DEFAULT_LIMIT}. Raise it only for a family whose rows a
+ * user browses; the unfiltered list is a preview, not a browser, and typing a
+ * query lifts the cap entirely — see `buildMentionPreview`.
+ */
+ mentionPreviewLimit?: number
}
function WorkflowDropdownItem({ item }: DropdownItemRenderProps) {
@@ -91,15 +99,37 @@ function IntegrationDropdownItem({ item }: DropdownItemRenderProps) {
)
}
+/**
+ * A run, not the workflow it ran — the Logs icon is what says so, and it is the
+ * same one the sidebar, the search palette, and the resulting chip already use.
+ *
+ * A run that did not simply succeed carries the same dot `Badge` draws at `sm`,
+ * so a status reads identically here and on the logs page. Marking every row
+ * would mark nothing, so a plain success gets none.
+ */
function LogDropdownItem({ item }: DropdownItemRenderProps) {
const workflowName = (item.workflowName as string) ?? item.name
const time = (item.time as string) ?? ''
+ const status = getDisplayStatus(item.status as string | null | undefined)
+ const statusColor = status === 'info' ? null : STATUS_CONFIG[status].color
return (
<>
-
+
{workflowName}
+ {statusColor && (
+
+ )}
{time && (
-
+
{time}
)}
@@ -219,6 +249,13 @@ export const RESOURCE_REGISTRY: Record {},
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts
index 5d5697d7be9..6578225b857 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts
@@ -1,17 +1,41 @@
-export const RESOURCE_TAB_GAP_CLASS = 'gap-1.5'
-
-export const RESOURCE_TAB_ICON_BUTTON_CLASS = 'shrink-0 bg-transparent px-2 py-[5px] text-caption'
+/**
+ * Icon-only controls in the resource header — add, preview mode, the per-resource
+ * actions — fill the tab strip's control band, so they match the strip's own
+ * new-tab button and the panel's collapse toggle and the header reads as one row.
+ */
+export const RESOURCE_TAB_ICON_BUTTON_CLASS = 'size-[var(--tab-strip-band,30px)] shrink-0 p-0'
export const RESOURCE_TAB_ICON_CLASS = 'size-[16px] text-[var(--text-icon)]'
/** Shared geometry for the resource header and controls positioned over it. */
export const RESOURCE_HEADER_CLASSES = {
layout:
- '[--resource-header-controls-height:43px] [--resource-header-end-inset:16px] [--resource-header-fixed-reserve:54px] [--resource-header-toggle-size:30px]',
- bar: 'h-[calc(var(--resource-header-controls-height)_+_1px)]',
+ '[--resource-header-controls-height:40px] [--resource-header-end-inset:16px] [--resource-header-fixed-reserve:54px] [--resource-header-toggle-size:30px]',
+ /**
+ * Drives the tab strip from this header's own tokens rather than restating the
+ * strip's defaults, so the height the overlaid controls below are positioned
+ * against and the height the strip renders at cannot drift apart. Set on the
+ * strip itself, not an ancestor — the browser and terminal strips nested in
+ * this panel keep their own geometry.
+ *
+ * The `+ 1px` is the strip's own bottom border. The controls height is the
+ * CONTENT box both clusters centre in, so the strip's box has to be a pixel
+ * taller than it or the tabs would centre in 43px while the overlaid toggle
+ * centres in 44px, and the two rows would sit half a pixel apart.
+ *
+ * The band is the tabs' own height, set below the 30px the collapse toggle
+ * keeps: a tab paints a fill, so its box is visible and wants air around it,
+ * where the toggle and the action buttons are bare glyphs whose box only shows
+ * on hover.
+ */
+ stripGeometry:
+ '[--tab-strip-height:calc(var(--resource-header-controls-height)_+_1px)] [--tab-strip-band:26px] [--tab-strip-max-tab-width:160px] [--tab-strip-inline-start:var(--resource-header-end-inset)] [--tab-strip-inline-end:var(--resource-header-fixed-reserve)]',
+ /**
+ * Centred, matching the `floating` strip: its tabs and controls sit centred in
+ * the header band rather than hanging from the top, so an overlaid control has
+ * to centre too or it lands a pixel below the row it belongs to.
+ */
overlay: 'absolute top-0 flex h-[var(--resource-header-controls-height)] items-center',
- startPadding: 'pl-[var(--resource-header-end-inset)]',
- endPadding: 'pr-[var(--resource-header-fixed-reserve)]',
endPosition: 'right-[var(--resource-header-end-inset)]',
/**
* Sits a control 1px clear of the overlaid 30px collapse toggle — the same
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
index b4333837c48..490467861e3 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
@@ -1,16 +1,24 @@
import {
type ComponentProps,
- type Dispatch,
- memo,
+ type DragEvent as ReactDragEvent,
+ type MouseEvent as ReactMouseEvent,
type ReactNode,
- type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
-import { Button, cn, Tooltip, tabStripWheelPosition } from '@sim/emcn'
+import {
+ Button,
+ cn,
+ TabStrip,
+ type TabStripDragContext,
+ type TabStripItem,
+ type TabStripSelectionSource,
+ Tooltip,
+ tabStripItemSelector,
+} from '@sim/emcn'
import { Columns3, Eye, Pencil } from '@sim/emcn/icons'
import { sendBrowserPanelAction } from '@/lib/browser-agent/transport'
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
@@ -22,7 +30,6 @@ import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/componen
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import {
RESOURCE_HEADER_CLASSES,
- RESOURCE_TAB_GAP_CLASS,
RESOURCE_TAB_ICON_BUTTON_CLASS,
RESOURCE_TAB_ICON_CLASS,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
@@ -41,9 +48,6 @@ import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
-const EDGE_ZONE = 40
-const SCROLL_SPEED = 8
-
/** Opens another inner tab when a singleton desktop resource already exists. */
export function openExistingResourceTab(
resource: MothershipResource,
@@ -96,10 +100,10 @@ function findNearestId(
* snapshotted it.
*/
function buildMultiDragImage(
- scrollNode: HTMLElement | null,
+ tabList: Element | null,
selected: MothershipResource[]
): HTMLElement | null {
- if (!scrollNode || selected.length === 0) return null
+ if (!tabList || selected.length === 0) return null
const container = document.createElement('div')
Object.assign(container.style, {
position: 'fixed',
@@ -113,9 +117,7 @@ function buildMultiDragImage(
} satisfies Partial)
let appendedAny = false
for (const r of selected) {
- const original = scrollNode.querySelector(
- `[data-resource-tab-id="${CSS.escape(r.id)}"]`
- )
+ const original = tabList.querySelector(tabStripItemSelector(r.id))
if (!original) continue
const clone = original.cloneNode(true) as HTMLElement
clone.style.opacity = '0.95'
@@ -140,10 +142,9 @@ const PREVIEW_MODE_LABELS: Record = {
}
/**
- * Stable identity for the empty lookup across `enabled` toggles. Unlike
- * `NO_RESOURCE_GROUPS`, nothing downstream keys on this identity — tab rows
- * receive the derived `displayName` string — so it is cheap insurance rather
- * than a guard against busting a downstream memo.
+ * Stable identity for the empty lookup across `enabled` toggles. The tab list
+ * memo below takes this map as a dependency, so a fresh empty map each time
+ * `enabled` flips would rebuild every tab for no change in what they say.
*/
const NO_RESOURCE_NAMES = new Map()
@@ -172,118 +173,6 @@ function useResourceNameLookup(workspaceId: string, enabled: boolean): Map void
- onDragOver: (e: React.DragEvent, idx: number) => void
- onDragLeave: () => void
- onDragEnd: () => void
- onTabClick: (e: React.MouseEvent, idx: number) => void
- setHoveredTabId: Dispatch>
- onRemove: (e: React.SyntheticEvent, resource: MothershipResource) => void
-}
-
-const ResourceTabItem = memo(function ResourceTabItem({
- resource,
- idx,
- isActive,
- isHovered,
- isDragging,
- isSelected,
- hasActivity,
- showGapBefore,
- showGapAfter,
- displayName,
- onDragStart,
- onDragOver,
- onDragLeave,
- onDragEnd,
- onTabClick,
- setHoveredTabId,
- onRemove,
-}: ResourceTabItemProps) {
- const config = getResourceConfig(resource.type)
- return (
-
- {showGapBefore && (
-
- )}
-
- {showGapAfter && (
-
- )}
-
- )
-})
-
interface ResourceTabsProps {
workspaceId: string
desktopScopeId: string
@@ -298,6 +187,15 @@ interface ResourceTabsProps {
onAddResourceClose?: () => Promise
}
+/**
+ * The resource panel's tab strip: the shared {@link TabStrip} plus the three
+ * things only this surface has — a multi-tab selection that drags into the chat
+ * as context, an add control that is a resource picker rather than a plain
+ * button, and the active resource's own actions trailing the row. Everything
+ * else — fixed tab widths, clipped-title tooltips, the scroll-edge fades,
+ * keyboard navigation, drag reordering — comes from the strip, which is the same
+ * component the browser and terminal panels nested inside this one use.
+ */
export function ResourceTabs({
workspaceId,
desktopScopeId,
@@ -319,59 +217,26 @@ export function ResourceTabs({
removeResource: onRemoveResource,
reorderResources: onReorderResources,
} = useMothershipResources()
- const scrollNodeRef = useRef(null)
-
- useEffect(() => {
- const node = scrollNodeRef.current
- if (!node) return
- const handler = (e: WheelEvent) => {
- const next = tabStripWheelPosition(
- node.scrollLeft,
- node.scrollWidth,
- node.clientWidth,
- e.deltaX,
- e.deltaY
- )
- if (next === null) return
- node.scrollLeft = next
- e.preventDefault()
- }
- node.addEventListener('wheel', handler, { passive: false })
- return () => node.removeEventListener('wheel', handler)
- }, [])
-
- useEffect(() => {
- const node = scrollNodeRef.current
- if (!node || !activeId) return
- const tab = node.querySelector(`[data-resource-tab-id="${CSS.escape(activeId)}"]`)
- if (!tab) return
- // Use bounding rects because the tab's offsetParent is a `position: relative`
- // wrapper, so `offsetLeft` is relative to that wrapper rather than `node`.
- const tabRect = tab.getBoundingClientRect()
- const nodeRect = node.getBoundingClientRect()
- const tabLeft = tabRect.left - nodeRect.left + node.scrollLeft
- const tabRight = tabLeft + tabRect.width
- const viewLeft = node.scrollLeft
- const viewRight = viewLeft + node.clientWidth
- if (tabLeft < viewLeft) {
- node.scrollTo({ left: tabLeft, behavior: 'smooth' })
- } else if (tabRight > viewRight) {
- node.scrollTo({ left: tabRight - node.clientWidth, behavior: 'smooth' })
- }
- }, [activeId])
const addResource = useAddChatResource(chatId)
const removeResource = useRemoveChatResource(chatId)
const reorderResources = useReorderChatResources(chatId)
- const [hoveredTabId, setHoveredTabId] = useState(null)
- const [draggedIdx, setDraggedIdx] = useState(null)
- const [dropGapIdx, setDropGapIdx] = useState(null)
const [selectedIds, setSelectedIds] = useState>(new Set())
- const dragStartIdx = useRef(null)
- const autoScrollRaf = useRef(null)
const anchorIdRef = useRef(null)
const prevChatIdRef = useRef(chatId)
+ // The drag image lives on `document.body` rather than in the React tree,
+ // because `setDragImage` snapshots a real, laid-out element. Holding it lets
+ // a drag whose source tab unmounts mid-gesture still be cleaned up.
+ const dragImageRef = useRef(null)
+
+ useEffect(
+ () => () => {
+ dragImageRef.current?.remove()
+ dragImageRef.current = null
+ },
+ []
+ )
// Reset selection when switching chats — component instance persists across
// chat switches so stale IDs would otherwise carry over.
@@ -381,7 +246,23 @@ export function ResourceTabs({
anchorIdRef.current = null
}
- const existingKeys = new Set(resources.map((r) => `${r.type}:${r.id}`))
+ const existingKeys = useMemo(
+ () => new Set(resources.map((r) => `${r.type}:${r.id}`)),
+ [resources]
+ )
+
+ const tabs = useMemo(
+ () =>
+ resources.map((resource) => ({
+ id: resource.id,
+ title: nameLookup.get(`${resource.type}:${resource.id}`) ?? resource.title,
+ icon: getResourceConfig(resource.type).renderTabIcon(resource, 'size-[16px] shrink-0'),
+ active: activeId === resource.id,
+ selected: selectedIds.size > 1 && selectedIds.has(resource.id),
+ attention: activityIds?.has(resource.id) ?? false,
+ })),
+ [resources, nameLookup, activeId, selectedIds, activityIds]
+ )
const handleAdd = useCallback(
(resource: MothershipResource) => {
@@ -405,13 +286,14 @@ export function ResourceTabs({
[desktopScopeId, selectResource]
)
- const handleTabClick = useCallback(
- (e: React.MouseEvent, idx: number) => {
+ const handleSelect = useCallback(
+ (id: string, _source?: TabStripSelectionSource, e?: ReactMouseEvent) => {
+ const idx = resources.findIndex((r) => r.id === id)
const resource = resources[idx]
if (!resource) return
// Shift+click: contiguous range from anchor
- if (e.shiftKey) {
+ if (e?.shiftKey) {
// Fall back to activeId when no explicit anchor exists (e.g. tab opened via sidebar)
const anchorId = anchorIdRef.current ?? activeId
const anchorIdx = anchorId ? resources.findIndex((r) => r.id === anchorId) : -1
@@ -427,7 +309,7 @@ export function ResourceTabs({
}
// Cmd/Ctrl+click: toggle individual tab in/out of selection
- if (e.metaKey || e.ctrlKey) {
+ if (e?.metaKey || e?.ctrlKey) {
const wasSelected = selectedIds.has(resource.id)
if (wasSelected) {
const next = new Set(selectedIds)
@@ -455,9 +337,10 @@ export function ResourceTabs({
[resources, selectResource, selectedIds, activeId]
)
- const handleRemove = useCallback(
- (e: React.SyntheticEvent, resource: MothershipResource) => {
- e.stopPropagation()
+ const handleClose = useCallback(
+ (id: string) => {
+ const resource = resources.find((r) => r.id === id)
+ if (!resource) return
const isMulti = selectedIds.has(resource.id) && selectedIds.size > 1
const targets = isMulti ? resources.filter((r) => selectedIds.has(r.id)) : [resource]
// Update parent state immediately for all targets
@@ -468,7 +351,7 @@ export function ResourceTabs({
const removedIds = new Set(targets.map((r) => r.id))
setSelectedIds((prev) => {
const next = new Set(prev)
- for (const id of removedIds) next.delete(id)
+ for (const removedId of removedIds) next.delete(removedId)
return next
})
if (anchorIdRef.current && removedIds.has(anchorIdRef.current)) {
@@ -488,29 +371,34 @@ export function ResourceTabs({
[chatId, onRemoveResource, resources, selectedIds]
)
- const handleDragStart = useCallback(
- (e: React.DragEvent, idx: number) => {
- const resource = resources[idx]
+ const handleTabDragStart = useCallback(
+ (e: ReactDragEvent, id: string, drag: TabStripDragContext) => {
+ const resource = resources.find((r) => r.id === id)
if (!resource) return
const selected = resources.filter((r) => selectedIds.has(r.id))
const isMultiDrag = selected.length > 1 && selectedIds.has(resource.id)
if (isMultiDrag) {
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData(SIM_RESOURCES_DRAG_TYPE, JSON.stringify(selected))
- const dragImage = buildMultiDragImage(scrollNodeRef.current, selected)
+ const dragImage = buildMultiDragImage(e.currentTarget.closest('[role="tablist"]'), selected)
if (dragImage) {
e.dataTransfer.setDragImage(dragImage, 16, 16)
- setTimeout(() => dragImage.remove(), 0)
+ dragImageRef.current = dragImage
+ setTimeout(() => {
+ dragImage.remove()
+ if (dragImageRef.current === dragImage) dragImageRef.current = null
+ }, 0)
}
- // Skip dragStartIdx so internal reorder is disabled for multi-select drags
- dragStartIdx.current = null
- setDraggedIdx(null)
+ // This gesture carries the whole selection out to the chat, so it is not
+ // a reorder; the strip drops its drag tracking rather than showing a
+ // drop indicator for a move that will never happen.
+ drag.preventReorder()
return
}
- dragStartIdx.current = idx
- setDraggedIdx(idx)
+ // `copyMove` because the strip already set `move` for its own reordering,
+ // and a drop target asking for `copy` is refused outright unless copying
+ // is allowed too.
e.dataTransfer.effectAllowed = 'copyMove'
- e.dataTransfer.setData('text/plain', String(idx))
e.dataTransfer.setData(
SIM_RESOURCE_DRAG_TYPE,
JSON.stringify({ type: resource.type, id: resource.id, title: resource.title })
@@ -519,78 +407,13 @@ export function ResourceTabs({
[resources, selectedIds]
)
- const stopAutoScroll = useCallback(() => {
- if (autoScrollRaf.current) {
- cancelAnimationFrame(autoScrollRaf.current)
- autoScrollRaf.current = null
- }
- }, [])
-
- const startEdgeScroll = useCallback(
- (clientX: number) => {
- const container = scrollNodeRef.current
- if (!container) return
- const cRect = container.getBoundingClientRect()
- if (autoScrollRaf.current) cancelAnimationFrame(autoScrollRaf.current)
- if (clientX < cRect.left + EDGE_ZONE) {
- const tick = () => {
- container.scrollLeft -= SCROLL_SPEED
- autoScrollRaf.current = requestAnimationFrame(tick)
- }
- autoScrollRaf.current = requestAnimationFrame(tick)
- } else if (clientX > cRect.right - EDGE_ZONE) {
- const tick = () => {
- container.scrollLeft += SCROLL_SPEED
- autoScrollRaf.current = requestAnimationFrame(tick)
- }
- autoScrollRaf.current = requestAnimationFrame(tick)
- } else {
- stopAutoScroll()
- }
- },
- [stopAutoScroll]
- )
-
- const handleDragOver = useCallback(
- (e: React.DragEvent, idx: number) => {
- e.preventDefault()
- e.dataTransfer.dropEffect = 'move'
- const rect = e.currentTarget.getBoundingClientRect()
- const midpoint = rect.left + rect.width / 2
- const gap = e.clientX < midpoint ? idx : idx + 1
- setDropGapIdx(gap)
- startEdgeScroll(e.clientX)
- },
- [startEdgeScroll]
- )
-
- const handleDragLeave = useCallback(() => {
- setDropGapIdx(null)
- stopAutoScroll()
- }, [stopAutoScroll])
-
- const handleDrop = useCallback(
- (e: React.DragEvent) => {
- e.preventDefault()
- stopAutoScroll()
- const fromIdx = dragStartIdx.current
- const gapIdx = dropGapIdx
- if (fromIdx === null || gapIdx === null) {
- setDraggedIdx(null)
- setDropGapIdx(null)
- dragStartIdx.current = null
- return
- }
- const insertAt = gapIdx > fromIdx ? gapIdx - 1 : gapIdx
- if (insertAt === fromIdx) {
- setDraggedIdx(null)
- setDropGapIdx(null)
- dragStartIdx.current = null
- return
- }
+ const handleReorder = useCallback(
+ (id: string, targetIndex: number) => {
+ const fromIndex = resources.findIndex((r) => r.id === id)
+ if (fromIndex < 0 || fromIndex === targetIndex) return
const reordered = [...resources]
- const [moved] = reordered.splice(fromIdx, 1)
- reordered.splice(insertAt, 0, moved)
+ const [moved] = reordered.splice(fromIndex, 1)
+ reordered.splice(targetIndex, 0, moved)
onReorderResources(reordered)
if (chatId) {
const persistable = reordered.filter((r) => !isEphemeralResource(r))
@@ -598,128 +421,65 @@ export function ResourceTabs({
reorderResources.mutate({ chatId, resources: persistable })
}
}
- setDraggedIdx(null)
- setDropGapIdx(null)
- dragStartIdx.current = null
},
// eslint-disable-next-line react-hooks/exhaustive-deps
- [chatId, resources, onReorderResources, dropGapIdx, stopAutoScroll]
+ [chatId, resources, onReorderResources]
)
- const handleDragEnd = useCallback(() => {
- stopAutoScroll()
- setDraggedIdx(null)
- setDropGapIdx(null)
- dragStartIdx.current = null
- }, [stopAutoScroll])
-
- const addResourceDropdown = (
-
- )
+ const previewToggle =
+ previewMode && onCyclePreviewMode ? (
+
+
+
+
+
+ {PREVIEW_MODE_LABELS[previewMode]}
+
+
+ ) : null
return (
-
-
-
{
- e.preventDefault()
- startEdgeScroll(e.clientX)
- }}
- onDrop={handleDrop}
- >
- {resources.map((resource, idx) => {
- const displayName = nameLookup.get(`${resource.type}:${resource.id}`) ?? resource.title
- const isActive = activeId === resource.id
- const isHovered = hoveredTabId === resource.id
- const isDragging = draggedIdx === idx
- const isSelected = selectedIds.has(resource.id) && selectedIds.size > 1
- const showGapBefore =
- dropGapIdx === idx &&
- draggedIdx !== null &&
- draggedIdx !== idx &&
- draggedIdx !== idx - 1
- const showGapAfter =
- idx === resources.length - 1 &&
- dropGapIdx === resources.length &&
- draggedIdx !== null &&
- draggedIdx !== idx
-
- return (
-
- )
- })}
-
- {/* Offered before the chat exists too: a resource opened while composing
- the first prompt is context for that prompt, and gating on a chat id
- meant the panel could be opened but not filled. */}
-
- {addResourceDropdown}
-
-
- {(actions || (previewMode && onCyclePreviewMode)) && (
-
- {actions}
- {previewMode && onCyclePreviewMode && (
-
-
-
-
-
- {PREVIEW_MODE_LABELS[previewMode]}
-
-
- )}
+
+
- )}
-
+ }
+ // A bare fragment is always truthy, so the empty case has to be `null` or
+ // the strip renders an empty trailing cluster.
+ endActions={
+ actions || previewToggle ? (
+ <>
+ {actions}
+ {previewToggle}
+ >
+ ) : null
+ }
+ />
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts
index 73eb6eff658..2cc097a1be2 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts
@@ -43,14 +43,24 @@ const PORTABLE_KIND_TO_ID_FIELD = {
*/
export type PortableKind = keyof typeof PORTABLE_KIND_TO_ID_FIELD
+/** Serializes a portable chip link, escaping Markdown delimiters in its label. */
+export function serializePortableChipLink(kind: PortableKind, id: string, label: string): string {
+ const escapedLabel = label.replace(/[\\[\]]/g, '\\$&')
+ return `[${escapedLabel}](${CHIP_LINK_SCHEME}:${kind}/${id})`
+}
+
+function parsePortableChipLabel(label: string): string {
+ return label.replace(/\\([\\[\]])/g, '$1')
+}
+
/**
* Matches a portable chip markdown link: `[label](sim:kind/id)`.
- * - group 1: label (any non-`]` chars)
+ * - group 1: label (plain or backslash-escaped characters)
* - group 2: kind (lowercase letters / underscores, e.g. `past_chat`)
* - group 3: id (any non-`)` / non-whitespace chars)
*/
const CHIP_LINK_PATTERN = new RegExp(
- `\\[([^\\]]+)\\]\\(${CHIP_LINK_SCHEME}:([a-z_]+)\\/([^)\\s]+)\\)`,
+ `\\[((?:\\\\.|[^\\]\\\\])+)\\]\\(${CHIP_LINK_SCHEME}:([a-z_]+)\\/([^)\\s]+)\\)`,
'g'
)
@@ -96,7 +106,7 @@ function serializeChipContext(context: ChatContext): string | null {
if (!isPortableKind(context.kind)) return null
const id = getPortableId(context)
if (!id) return null
- return `[${context.label}](${CHIP_LINK_SCHEME}:${context.kind}/${id})`
+ return serializePortableChipLink(context.kind, id, context.label)
}
/**
@@ -205,7 +215,7 @@ export function parseChipLinks(text: string): ParsedChipLink[] {
links.push({
kind,
id,
- label,
+ label: parsePortableChipLabel(label),
start: match.index,
end: match.index + full.length,
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts
index f24b1890ee7..6d1658abfc3 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts
@@ -125,7 +125,12 @@ const RESOURCE_TO_CONTEXT: Record<
folder: (r) => ({ kind: 'folder', folderId: r.id, label: r.title }),
filefolder: (r) => ({ kind: 'filefolder', fileFolderId: r.id, label: r.title }),
task: (r) => ({ kind: 'past_chat', chatId: r.id, label: r.title }),
- log: (r) => ({ kind: 'logs', executionId: r.id, label: r.title }),
+ // Addressed by run, not by log row: `id` is the row's key, and the server
+ // resolves this context against `workflow_execution_logs.execution_id`. A
+ // picked resource carries the run id; one rebuilt from the wire (a restored
+ // or agent-opened tab) cannot, since the stored and streamed resource shapes
+ // are the identity triple — those keep the row id they have always sent.
+ log: (r) => ({ kind: 'logs', executionId: r.executionId ?? r.id, label: r.title }),
integration: (r) => ({ kind: 'integration', blockType: r.id, label: r.title }),
generic: (r) => ({ kind: 'docs', label: r.title }),
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx
index a16be8f1c04..1bc821bd712 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx
@@ -5,17 +5,24 @@ import {
cn,
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuLabel,
DropdownMenuSearchInput,
DropdownMenuTrigger,
+ dropdownMenuRowClass,
} from '@sim/emcn'
import {
ResourceMenuSections,
+ resourceFromItem,
useAvailableResources,
useResourceTreeSections,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
-import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
+import {
+ getResourceConfig,
+ MENTION_PREVIEW_DEFAULT_LIMIT,
+} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
import {
+ buildMentionPreview,
resourceMentionMatches,
withDesktopTabMentions,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items'
@@ -26,6 +33,14 @@ import type {
import { useBrowserSessionStore } from '@/stores/browser-session/store'
import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store'
+/**
+ * The `@` list is shorter than the emcn menu default (420px, sized for right-click
+ * action menus). This one floats directly over the chat input, so a menu tall enough
+ * to swallow the conversation behind it reads as a takeover rather than an
+ * autocomplete. ~10 rows is enough to show several families at once.
+ */
+const MENTION_MAX_HEIGHT_CLASS = 'max-h-[min(280px,var(--radix-popper-available-height,280px))]'
+
/**
* Resource types that are only offered via `@`-mention autocomplete and hidden
* from the `+` browse menu. Integrations are searchable inline (e.g. typing
@@ -127,10 +142,12 @@ export const PlusMenuDropdown = React.memo(
const filteredItems = useMemo(() => {
const rawQuery = isMention ? (mentionQuery ?? '') : search
const q = rawQuery.toLowerCase().trim()
- // In mention mode always render a flat filtered list — empty query = show everything.
if (!isMention && !q) return null
if (isMention && !q) {
- return visibleResources.flatMap(({ type, items }) => items.map((item) => ({ type, item })))
+ return buildMentionPreview(
+ visibleResources,
+ (type) => getResourceConfig(type).mentionPreviewLimit ?? MENTION_PREVIEW_DEFAULT_LIMIT
+ )
}
return visibleResources.flatMap(({ type, items }) =>
items.filter((item) => resourceMentionMatches(item, q)).map((item) => ({ type, item }))
@@ -181,11 +198,7 @@ export const PlusMenuDropdown = React.memo(
const items = filteredItemsRef.current
const target = items?.length ? (items[activeIndexRef.current] ?? items[0]) : undefined
if (!target) return isHydratingRef.current ? 'hydrating' : 'empty'
- handleSelectRef.current({
- type: target.type,
- id: target.item.id,
- title: target.item.name,
- })
+ handleSelectRef.current(resourceFromItem(target.type, target.item))
return 'selected'
},
}),
@@ -224,7 +237,7 @@ export const PlusMenuDropdown = React.memo(
} else if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault()
const target = filteredItems[activeIndex] ?? filteredItems[0]
- if (target) handleSelect({ type: target.type, id: target.item.id, title: target.item.name })
+ if (target) handleSelect(resourceFromItem(target.type, target.item))
}
}
@@ -298,7 +311,7 @@ export const PlusMenuDropdown = React.memo(
// Plus-click shows short fixed labels (Workflows, Tables, …) — let it size
// to its content via the emcn DropdownMenuContent default max-w.
// Mention mode renders resource names directly, so widen for breathing room.
- isMention && 'max-w-[min(300px,calc(100vw-32px))]'
+ isMention && `max-w-[min(300px,calc(100vw-32px))] ${MENTION_MAX_HEIGHT_CLASS}`
)}
onCloseAutoFocus={handleCloseAutoFocus}
onOpenAutoFocus={handleOpenAutoFocus}
@@ -334,28 +347,36 @@ export const PlusMenuDropdown = React.memo(
filteredItems.map(({ type, item }, index) => {
const config = getResourceConfig(type)
const isActive = index === activeIndex
+ /* Items arrive grouped by family (one group per type, ordered by
+ RESOURCE_MENU_ORDER), so a type change marks a section boundary.
+ Deriving the heading from the flat list keeps `activeIndex` — and
+ therefore every keyboard path — indexing exactly what it did. */
+ const startsSection = index === 0 || filteredItems[index - 1]?.type !== type
return (
-
+
+ {startsSection && {config.label}}
+
+
)
})
) : (
-
+
No results
))}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts
index bed14025882..f99953e1681 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.test.ts
@@ -3,7 +3,9 @@ import {
BROWSER_SESSION_RESOURCE_ID,
TERMINAL_SESSION_RESOURCE_ID,
} from '@/lib/copilot/resources/types'
+import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree'
import {
+ buildMentionPreview,
resourceMentionMatches,
withDesktopTabMentions,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items'
@@ -106,3 +108,53 @@ describe('withDesktopTabMentions', () => {
expect(resourceMentionMatches(tab, 'terminal')).toBe(false)
})
})
+
+describe('buildMentionPreview', () => {
+ const item = (id: string): AvailableItem => ({ id, name: id })
+ const many = (n: number) => Array.from({ length: n }, (_, i) => item(`i${i}`))
+
+ it('caps each family so a large one cannot bury the families after it', () => {
+ const preview = buildMentionPreview(
+ [
+ { type: 'integration', items: many(300) },
+ { type: 'workflow', items: [item('thermal-field')] },
+ ],
+ () => 5
+ )
+
+ expect(preview.filter((c) => c.type === 'integration')).toHaveLength(5)
+ expect(preview.map((c) => c.item.id)).toContain('thermal-field')
+ })
+
+ it('lets a family raise its own cap', () => {
+ const preview = buildMentionPreview(
+ [
+ { type: 'integration', items: many(10) },
+ { type: 'workflow', items: many(10) },
+ ],
+ (type) => (type === 'workflow' ? 2 : 5)
+ )
+
+ expect(preview.filter((c) => c.type === 'integration')).toHaveLength(5)
+ expect(preview.filter((c) => c.type === 'workflow')).toHaveLength(2)
+ })
+
+ it('keeps families in the order they were given, so headings stay contiguous', () => {
+ const preview = buildMentionPreview(
+ [
+ { type: 'integration', items: many(3) },
+ { type: 'workflow', items: many(3) },
+ ],
+ () => 5
+ )
+
+ const boundaries = preview.filter((c, i) => i > 0 && preview[i - 1].type !== c.type)
+ expect(boundaries).toHaveLength(1)
+ expect(preview.at(-1)?.type).toBe('workflow')
+ })
+
+ it('keeps a family shorter than the cap intact', () => {
+ const preview = buildMentionPreview([{ type: 'workflow', items: many(2) }], () => 5)
+ expect(preview).toHaveLength(2)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts
index bbbe84ad29e..fb68fd2ce50 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/resource-mention-items.ts
@@ -92,3 +92,30 @@ export function withDesktopTabMentions(
return group
})
}
+
+/** One row of the `@` list: an item plus the family it came from. */
+export interface ResourceMentionCandidate {
+ type: MothershipResourceType
+ item: AvailableItem
+}
+
+/**
+ * The rows an `@` list shows for an EMPTY query — a preview of what is mentionable,
+ * capped per family so no one family can bury the rest.
+ *
+ * `integration` carries 300+ near-identical rows and sorts FIRST, so while the cap
+ * defaulted to "uncapped" the preview was its entire catalog and no other family was
+ * reachable without scrolling past all of it. Capping is therefore the default and a
+ * family opts out by raising its own limit, not by omitting one.
+ *
+ * Only the empty-query preview is capped; {@link resourceMentionMatches} searches
+ * every family in full once the user types.
+ */
+export function buildMentionPreview(
+ groups: readonly ResourceMentionGroup[],
+ limitFor: (type: MothershipResourceType) => number
+): ResourceMentionCandidate[] {
+ return groups.flatMap(({ type, items }) =>
+ items.slice(0, limitFor(type)).map((item) => ({ type, item }))
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx
index c350b1d6be9..1463cf83d73 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx
@@ -1,7 +1,13 @@
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { cn, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@sim/emcn'
+import {
+ cn,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+ dropdownMenuRowClass,
+} from '@sim/emcn'
import { AgentSkillsIcon, McpIcon } from '@/components/icons'
import type { McpServer } from '@/hooks/queries/mcp'
import type { SkillDefinition } from '@/hooks/queries/skills'
@@ -210,7 +216,8 @@ export const SkillsMenuDropdown = React.memo(
onMouseEnter={() => setActiveIndex(index)}
onClick={() => handleSelect(target)}
className={cn(
- 'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left text-[var(--text-body)] text-caption outline-none transition-colors duration-0 [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
+ dropdownMenuRowClass,
+ 'w-full text-left',
/* `activeIndex` is the cursor, not a selection — hover surface. */
isActive && 'bg-[var(--surface-hover)]'
)}
@@ -221,7 +228,7 @@ export const SkillsMenuDropdown = React.memo(
)
})
) : (
-
+
No skills or MCP servers
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
index 66bd244c680..ddc31f59d36 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
@@ -652,6 +652,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
) : (
m.executeLocalFilesystemTool(toolCallId, toolName, toolArgs, options),
+ async (error) => {
+ logger.error('Failed to load local filesystem tool executor', { error })
+ /**
+ * The recovery itself can reject (the helper chunks or the completion POST can
+ * fail for the same reason the executor chunk did). Contain it: an unhandled
+ * rejection here would settle nothing and surface as a console error, exactly
+ * like the executor's own report-failure path, which also degrades to a log.
+ */
+ try {
+ const [{ reportClientToolCompletion }, { ASYNC_TOOL_CONFIRMATION_STATUS }] =
+ await Promise.all([
+ import('@/lib/copilot/tools/client/completion'),
+ import('@/lib/copilot/async-runs/lifecycle'),
+ ])
+ await reportClientToolCompletion(
+ toolCallId,
+ ASYNC_TOOL_CONFIRMATION_STATUS.error,
+ 'Local filesystem tool failed to load'
+ )
+ } catch (reportError) {
+ logger.error('Failed to report local filesystem tool load failure', {
+ toolCallId,
+ error: reportError,
+ })
+ }
+ }
+ )
},
[workspaceId]
)
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx
index 605dfa1f53a..e7488424da5 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx
@@ -8,6 +8,10 @@ import {
DropdownMenuTrigger,
} from '@sim/emcn'
import { Duplicate, Eye, Pencil, Plus, SquareArrowUpRight, Trash } from '@sim/emcn/icons'
+import {
+ selectionActionLabel,
+ selectionToggleActionLabel,
+} from '@/app/workspace/[workspaceId]/components/resource/selection-label'
interface ChunkContextMenuProps {
isOpen: boolean
@@ -26,14 +30,14 @@ interface ChunkContextMenuProps {
disableAddChunk?: boolean
disableEdit?: boolean
isConnectorDocument?: boolean
- selectedCount?: number
+ selectedCount: number
enabledCount?: number
disabledCount?: number
}
/**
* Context menu for chunks table.
- * Shows chunk actions when right-clicking a row, or "Create chunk" when right-clicking empty space.
+ * Shows chunk actions when right-clicking a row, or "New chunk" when right-clicking empty space.
* Supports batch operations when multiple chunks are selected.
*/
export function ChunkContextMenu({
@@ -53,24 +57,23 @@ export function ChunkContextMenu({
disableAddChunk = false,
disableEdit = false,
isConnectorDocument = false,
- selectedCount = 1,
+ selectedCount,
enabledCount = 0,
disabledCount = 0,
}: ChunkContextMenuProps) {
const isMultiSelect = selectedCount > 1
-
- const getToggleLabel = () => {
- if (isMultiSelect) {
- if (disabledCount > 0) return 'Enable'
- return 'Disable'
- }
- return isChunkEnabled ? 'Disable' : 'Enable'
- }
+ const toggleLabel = selectionToggleActionLabel({
+ selectedCount,
+ enabledCount,
+ disabledCount,
+ isSelectedItemEnabled: isChunkEnabled,
+ })
const hasNavigationSection = !isMultiSelect && !!onOpenInNewTab
const hasEditSection = !isMultiSelect && (!!onEdit || !!onCopyContent)
const hasStateSection = !!onToggleEnabled
const hasDestructiveSection = !!onDelete
+ const hasActionsAboveDestructive = hasNavigationSection || hasEditSection || hasStateSection
return (
!open && onClose()} modal={false}>
@@ -102,11 +105,6 @@ export function ChunkContextMenu({
Open in new tab
)}
- {hasNavigationSection &&
- (hasEditSection || hasStateSection || hasDestructiveSection) && (
-
- )}
-
{!isMultiSelect && onEdit && (
@@ -119,22 +117,18 @@ export function ChunkContextMenu({
Copy content
)}
- {hasEditSection && (hasStateSection || hasDestructiveSection) && (
-
- )}
-
{onToggleEnabled && (
- {getToggleLabel()}
+ {toggleLabel}
)}
- {hasStateSection && hasDestructiveSection && }
+ {hasActionsAboveDestructive && hasDestructiveSection && }
{onDelete && (
- Delete
+ {selectionActionLabel('Delete', selectedCount)}
)}
>
@@ -142,7 +136,7 @@ export function ChunkContextMenu({
onAddChunk && (
- Create chunk
+ New chunk
)
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx
index fd6e1f23667..db86183c216 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx
@@ -6,7 +6,7 @@ import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { getKnowledgeChunkContract } from '@/lib/api/contracts/knowledge'
import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
-import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
+import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/accurate'
import { useCreateChunk, useUpdateChunk } from '@/hooks/queries/kb/knowledge'
import { useAutosave } from '@/hooks/use-autosave'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
index 3edc0720140..2c381a7cf1f 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
@@ -59,7 +59,6 @@ import {
import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
-import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge'
import {
@@ -69,6 +68,7 @@ import {
useUpdateChunk,
useUpdateDocument,
} from '@/hooks/queries/kb/knowledge'
+import { useContextMenu } from '@/hooks/use-context-menu'
import { useDebounce } from '@/hooks/use-debounce'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
import { useInlineRename } from '@/hooks/use-inline-rename'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
index 95c2e69871d..7e5d1e45483 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
@@ -41,7 +41,12 @@ import { format } from 'date-fns'
import { useParams, useRouter } from 'next/navigation'
import { useQueryState, useQueryStates } from 'nuqs'
import { usePostHog } from 'posthog-js/react'
-import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants'
+import {
+ ALL_TAG_SLOTS,
+ type AllTagSlot,
+ getFieldTypeForSlot,
+ KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS,
+} from '@/lib/knowledge/constants'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
import type { DocumentData } from '@/lib/knowledge/types'
@@ -53,7 +58,6 @@ import type {
FilterTag,
ResourceAction,
ResourceCell,
- ResourceColumn,
ResourceRow,
SelectableConfig,
SortConfig,
@@ -73,7 +77,13 @@ import {
useFolderAncestors,
} from '@/app/workspace/[workspaceId]/components/folders'
import { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
-import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components'
+/**
+ * Deep import on purpose: the `[documentId]/components` barrel also exports `ChunkEditor`,
+ * which needs exact token counts and therefore `js-tiktoken` (~2.5 MB gzip of BPE rank
+ * tables). Importing the modal through the barrel shipped the tokenizer to the document
+ * LIST route, which never edits chunks.
+ */
+import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal'
import {
ActionBar,
AddConnectorModal,
@@ -83,6 +93,7 @@ import {
DocumentContextMenu,
RenameDocumentModal,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
+import { DOCUMENT_COLUMNS } from '@/app/workspace/[workspaceId]/knowledge/[id]/document-columns'
import {
addConnectorParam,
documentFiltersParsers,
@@ -92,14 +103,18 @@ import {
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
-import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { BrandIcon } from '@/blocks/brand-icon'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
-import { useKnowledgeBase, useKnowledgeBaseDocuments } from '@/hooks/kb/use-knowledge'
+import {
+ hasProcessingDocuments,
+ useKnowledgeBase,
+ useKnowledgeBaseDocuments,
+} from '@/hooks/kb/use-knowledge'
import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
+import type { ConnectorData } from '@/hooks/queries/kb/connectors'
import { isConnectorSyncingOrPending, useConnectorList } from '@/hooks/queries/kb/connectors'
import type { DocumentTagFilter } from '@/hooks/queries/kb/knowledge'
import {
@@ -109,6 +124,7 @@ import {
useUpdateDocument,
useUpdateKnowledgeBase,
} from '@/hooks/queries/kb/knowledge'
+import { useContextMenu } from '@/hooks/use-context-menu'
import { useDebounce } from '@/hooks/use-debounce'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
import { useInlineRename } from '@/hooks/use-inline-rename'
@@ -117,17 +133,27 @@ import { useUrlSort } from '@/hooks/use-url-sort'
const logger = createLogger('KnowledgeBase')
+/**
+ * Identifies one processing *run*, not one document.
+ *
+ * Keying on the attempt's start time makes the reported-set self-invalidating:
+ * a document that is retried gets a new `processingStartedAt`, so a later stall
+ * is reportable again without the set needing to be pruned.
+ */
+function deadProcessKey(doc: Pick) {
+ return `${doc.id}:${doc.processingStartedAt ?? ''}`
+}
+
const DOCUMENTS_PER_PAGE = 50
-const DOCUMENT_COLUMNS: ResourceColumn[] = [
- { id: 'name', header: 'Name', widthMultiplier: 0.8 },
- { id: 'size', header: 'Size', widthMultiplier: 0.75 },
- { id: 'tokens', header: 'Tokens', widthMultiplier: 0.75 },
- { id: 'chunks', header: 'Chunks', widthMultiplier: 0.75 },
- { id: 'uploaded', header: 'Uploaded' },
- { id: 'status', header: 'Status', widthMultiplier: 0.75 },
- { id: 'tags', header: 'Tags' },
-]
+/** Stable identity so an absent connector list does not re-fire list-dependent effects. */
+const EMPTY_CONNECTORS: ConnectorData[] = []
+
+/** Cadence while a document is still indexing — its own status is what moves. */
+const PROCESSING_POLL_INTERVAL_MS = 3000
+
+/** Slower cadence while only a connector sync is running: rows arrive in batches. */
+const CONNECTOR_SYNC_DOCUMENT_POLL_INTERVAL_MS = 5000
const STATUS_FILTER_OPTIONS: ChipDropdownOption[] = [
{ value: 'all', label: 'All' },
@@ -407,7 +433,8 @@ export function KnowledgeBase({
refresh: refreshKnowledgeBase,
} = useKnowledgeBase(id)
- const { data: connectors = [], isLoading: isLoadingConnectors } = useConnectorList(id)
+ const { data: connectors = EMPTY_CONNECTORS, isLoading: isLoadingConnectors } =
+ useConnectorList(id)
const hasSyncingConnectors = connectors.some(isConnectorSyncingOrPending)
const hasSyncingConnectorsRef = useRef(hasSyncingConnectors)
hasSyncingConnectorsRef.current = hasSyncingConnectors
@@ -418,7 +445,6 @@ export function KnowledgeBase({
isLoading: isLoadingDocuments,
isPlaceholderData: isPlaceholderDocuments,
error: documentsError,
- hasProcessingDocuments,
updateDocument,
refreshDocuments,
} = useKnowledgeBaseDocuments(id, {
@@ -429,11 +455,8 @@ export function KnowledgeBase({
sortOrder: sortDirection as SortOrder,
refetchInterval: (data) => {
if (isDeleting) return false
- const hasPending = data?.documents?.some(
- (doc) => doc.processingStatus === 'pending' || doc.processingStatus === 'processing'
- )
- if (hasPending) return 3000
- if (hasSyncingConnectorsRef.current) return 5000
+ if (hasProcessingDocuments(data?.documents ?? [])) return PROCESSING_POLL_INTERVAL_MS
+ if (hasSyncingConnectorsRef.current) return CONNECTOR_SYNC_DOCUMENT_POLL_INTERVAL_MS
return false
},
enabledFilter: enabledFilter,
@@ -489,20 +512,27 @@ export function KnowledgeBase({
const totalPages = Math.ceil(pagination.total / pagination.limit)
/**
- * Checks for documents with stale processing states and marks them as failed
+ * Processing runs already reported as timed out.
+ *
+ * The list below polls every few seconds while anything is processing, and
+ * each poll hands this effect a new array. Without this the same stale
+ * document is re-reported on every tick until the server's new status comes
+ * back — one redundant write per poll, per open tab.
*/
+ const reportedDeadProcessesRef = useRef | null>(null)
+
const checkForDeadProcesses = useCallback(
(docsToCheck: DocumentData[]) => {
- const now = new Date()
- const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes
+ const reported = (reportedDeadProcessesRef.current ??= new Set())
+ const nowMs = Date.now()
const staleDocuments = docsToCheck.filter((doc) => {
- if (doc.processingStatus !== 'processing' || !doc.processingStartedAt) {
- return false
- }
-
- const processingDuration = now.getTime() - new Date(doc.processingStartedAt).getTime()
- return processingDuration > DEAD_PROCESS_THRESHOLD_MS
+ if (doc.processingStatus !== 'processing' || !doc.processingStartedAt) return false
+ if (reported.has(deadProcessKey(doc))) return false
+ return (
+ nowMs - new Date(doc.processingStartedAt).getTime() >
+ KNOWLEDGE_DOCUMENT_PROCESSING_STALE_THRESHOLD_MS
+ )
})
if (staleDocuments.length === 0) return
@@ -510,6 +540,7 @@ export function KnowledgeBase({
logger.warn(`Found ${staleDocuments.length} documents with dead processes`)
staleDocuments.forEach((doc) => {
+ reported.add(deadProcessKey(doc))
updateDocumentMutation(
{
knowledgeBaseId: id,
@@ -522,6 +553,8 @@ export function KnowledgeBase({
`Successfully marked dead process as failed for document: ${doc.filename}`
)
},
+ /** Retried on the next poll rather than left silently unreported. */
+ onError: () => reported.delete(deadProcessKey(doc)),
}
)
})
@@ -530,10 +563,8 @@ export function KnowledgeBase({
)
useEffect(() => {
- if (hasProcessingDocuments) {
- checkForDeadProcesses(documents)
- }
- }, [hasProcessingDocuments, documents, checkForDeadProcesses])
+ checkForDeadProcesses(documents)
+ }, [documents, checkForDeadProcesses])
const handleToggleEnabled = (docId: string) => {
const document = documents.find((doc) => doc.id === docId)
@@ -662,6 +693,7 @@ export function KnowledgeBase({
* Handles selecting/deselecting a document
*/
const handleSelectDocument = (docId: string, checked: boolean) => {
+ setIsSelectAllMode(false)
setSelectedDocuments((prev) => {
const newSet = new Set(prev)
if (checked) {
@@ -883,6 +915,7 @@ export function KnowledgeBase({
? 0
: pagination.total
: selectedDocumentsList.filter((doc) => !doc.enabled).length
+ const selectedDocumentCount = isSelectAllMode ? pagination.total : selectedDocuments.size
const handleDocumentContextMenu = useCallback(
(e: React.MouseEvent, docId: string) => {
@@ -892,6 +925,7 @@ export function KnowledgeBase({
const isCurrentlySelected = selectedDocuments.has(doc.id)
if (!isCurrentlySelected) {
+ setIsSelectAllMode(false)
setSelectedDocuments(new Set([doc.id]))
}
@@ -1083,6 +1117,7 @@ export function KnowledgeBase({
{connectors.map((connector) => {
const def = CONNECTOR_META_REGISTRY[connector.connectorType]
const ConnectorIcon = def?.icon
+ const syncInFlight = isConnectorSyncingOrPending(connector)
return (