Compare commits

..

9 Commits

Author SHA1 Message Date
Waleed
14089f7dbb v0.6.14: performance improvements, connectors UX, collapsed sidebar actions 2026-03-27 13:07:59 -07:00
Waleed
b90bb75cda fix(knowledge): connector spinner race condition + connectors column (#3812)
* fix(knowledge): scope sync/update state per-connector to prevent race conditions

* feat(knowledge): add connectors column to knowledge base list

* refactor(knowledge): extract set helpers, handleTogglePause, and filter-before-map

* refactor(knowledge): use onSettled for syncingIds cleanup, consistent with updatingIds
2026-03-27 12:54:14 -07:00
Waleed
fb233d003d fix(flyout): align inline rename with non-rename styling (#3811) 2026-03-27 12:39:23 -07:00
Waleed
34df3333d1 fix(knowledge): fix search input flicker on clear and plan display name fallback (#3810) 2026-03-27 12:23:41 -07:00
Waleed
23677d41a0 improvement(sidebar): collapsed sidebar UX, quick-create, hover consistency, and UI polish (#3807)
* improvement(sidebar): collapsed sidebar UX, quick-create, hover consistency, and UI polish

Made-with: Cursor

* fix(sidebar): use stable handlers for root workflow items instead of inline lambdas

Made-with: Cursor

* fix(sidebar): reset actionsOpen state before triggering rename in collapsed dropdown

Made-with: Cursor
2026-03-27 12:08:17 -07:00
Waleed
a489f91085 fix(knowledge): show spinner on connector chip while syncing (#3808)
* fix(knowledge): show spinner on connector chip while syncing

* fix(knowledge): scope sync spinner to mutation lifetime, not cooldown
2026-03-27 12:04:11 -07:00
Adithya Krishna
ed6e7845cc chore: fix rerenders on files (#3805)
* chore: fix rerenders on files

* chore: fix review changes
2026-03-27 11:48:51 -07:00
Adithya Krishna
e698f9fe14 chore: remove font antialiasing (#3806)
* chore: fix antialiasing

* chore: remove antialiasing
2026-03-27 11:29:37 -07:00
Adithya Krishna
db1798267e feat: update sidebar and knowledge (#3804)
* feat: update sidebar and knowledge

* chore: fix rernders on knowledge

* chore: fix review changes

* chore: fix review changes
2026-03-27 09:39:41 -07:00
48 changed files with 1407 additions and 778 deletions

View File

@@ -188,7 +188,8 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
--border-1: #e0e0e0; /* stronger border */
--surface-6: #e5e5e5; /* popovers, elevated surfaces */
--surface-7: #d9d9d9;
--surface-active: #ececec; /* hover/active state */
--surface-hover: #f2f2f2; /* hover state */
--surface-active: #ececec; /* active/selected state */
--workflow-edge: #e0e0e0; /* workflow handles/edges - matches border-1 */
@@ -342,7 +343,8 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
--border-1: #3d3d3d;
--surface-6: #454545;
--surface-7: #505050;
--surface-active: #2c2c2c; /* hover/active state */
--surface-hover: #262626; /* hover state */
--surface-active: #2c2c2c; /* active/selected state */
--workflow-edge: #454545; /* workflow handles/edges - same as surface-6 in dark */
@@ -501,9 +503,6 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
caret-color: var(--text-primary);
}
body {
@apply antialiased;
}
::-webkit-scrollbar {
width: var(--scrollbar-size);
height: var(--scrollbar-size);

View File

@@ -1,7 +1,13 @@
import { memo } from 'react'
import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource'
import type { WorkspaceMember } from '@/hooks/queries/workspace'
function OwnerAvatar({ name, image }: { name: string; image: string | null }) {
interface OwnerAvatarProps {
name: string
image: string | null
}
const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) {
if (image) {
return (
<img
@@ -18,7 +24,7 @@ function OwnerAvatar({ name, image }: { name: string; image: string | null }) {
{name.charAt(0).toUpperCase()}
</span>
)
}
})
/**
* Resolves a user ID into a ResourceCell with an avatar icon and display name.

View File

@@ -11,6 +11,8 @@ import {
import { cn } from '@/lib/core/utils/cn'
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
const HEADER_PLUS_ICON = <Plus className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
export interface DropdownOption {
label: string
icon?: React.ElementType
@@ -122,7 +124,7 @@ export const ResourceHeader = memo(function ResourceHeader({
variant='subtle'
className='px-2 py-1 text-caption'
>
<Plus className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
{HEADER_PLUS_ICON}
{create.label}
</Button>
)}
@@ -132,19 +134,21 @@ export const ResourceHeader = memo(function ResourceHeader({
)
})
function BreadcrumbSegment({
icon: Icon,
label,
onClick,
dropdownItems,
editing,
}: {
interface BreadcrumbSegmentProps {
icon?: React.ElementType
label: string
onClick?: () => void
dropdownItems?: DropdownOption[]
editing?: BreadcrumbEditing
}) {
}
const BreadcrumbSegment = memo(function BreadcrumbSegment({
icon: Icon,
label,
onClick,
dropdownItems,
editing,
}: BreadcrumbSegmentProps) {
if (editing?.isEditing) {
return (
<span className='inline-flex items-center px-2 py-1'>
@@ -203,4 +207,4 @@ function BreadcrumbSegment({
{content}
</span>
)
}
})

View File

@@ -1,4 +1,4 @@
import { memo, type ReactNode } from 'react'
import { memo, type ReactNode, useCallback, useRef, useState } from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import {
ArrowDown,
@@ -16,6 +16,12 @@ import {
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
const SEARCH_ICON = (
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
)
const FILTER_ICON = <ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
const SORT_ICON = <ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
type SortDirection = 'asc' | 'desc'
export interface ColumnOption {
@@ -79,56 +85,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
return (
<div className={cn('border-[var(--border)] border-b py-2.5', search ? 'px-6' : 'px-4')}>
<div className='flex items-center justify-between'>
{search && (
<div className='relative flex flex-1 items-center'>
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto pl-2.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{search.tags?.map((tag, i) => (
<Button
key={`${tag.label}-${tag.value}-${i}`}
variant='subtle'
className={cn(
'shrink-0 px-2 py-1 text-caption',
search.highlightedTagIndex === i &&
'ring-1 ring-[var(--border-focus)] ring-offset-1'
)}
onClick={tag.onRemove}
>
{tag.label}: {tag.value}
<span className='ml-1 text-[var(--text-icon)] text-micro'></span>
</Button>
))}
<input
ref={search.inputRef}
type='text'
value={search.value}
onChange={(e) => search.onChange(e.target.value)}
onKeyDown={search.onKeyDown}
onFocus={search.onFocus}
onBlur={search.onBlur}
placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')}
className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-secondary)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
{search.tags?.length || search.value ? (
<button
type='button'
className='mr-0.5 flex h-[14px] w-[14px] shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover-hover:text-[var(--text-secondary)]'
onClick={search.onClearAll}
>
<span className='text-caption'></span>
</button>
) : null}
{search.dropdown && (
<div
ref={search.dropdownRef}
className='absolute top-full left-0 z-50 mt-1.5 w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
>
{search.dropdown}
</div>
)}
</div>
)}
{search && <SearchSection search={search} />}
<div className='flex items-center gap-1.5'>
{extras}
{filterTags?.map((tag) => (
@@ -146,7 +103,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
<PopoverPrimitive.Root>
<PopoverPrimitive.Trigger asChild>
<Button variant='subtle' className='px-2 py-1 text-caption'>
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
{FILTER_ICON}
Filter
</Button>
</PopoverPrimitive.Trigger>
@@ -170,14 +127,94 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
)
})
function SortDropdown({ config }: { config: SortConfig }) {
const SearchSection = memo(function SearchSection({ search }: { search: SearchConfig }) {
const [localValue, setLocalValue] = useState(search.value)
const lastReportedRef = useRef(search.value)
if (search.value !== lastReportedRef.current) {
setLocalValue(search.value)
lastReportedRef.current = search.value
}
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value
setLocalValue(next)
search.onChange(next)
},
[search.onChange]
)
const handleClearAll = useCallback(() => {
setLocalValue('')
lastReportedRef.current = ''
if (search.onClearAll) {
search.onClearAll()
} else {
search.onChange('')
}
}, [search.onClearAll, search.onChange])
return (
<div className='relative flex flex-1 items-center'>
{SEARCH_ICON}
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto pl-2.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{search.tags?.map((tag, i) => (
<Button
key={`${tag.label}-${tag.value}-${i}`}
variant='subtle'
className={cn(
'shrink-0 px-2 py-1 text-caption',
search.highlightedTagIndex === i && 'ring-1 ring-[var(--border-focus)] ring-offset-1'
)}
onClick={tag.onRemove}
>
{tag.label}: {tag.value}
<span className='ml-1 text-[var(--text-icon)] text-micro'></span>
</Button>
))}
<input
ref={search.inputRef}
type='text'
value={localValue}
onChange={handleInputChange}
onKeyDown={search.onKeyDown}
onFocus={search.onFocus}
onBlur={search.onBlur}
placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')}
className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-secondary)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
{search.tags?.length || localValue ? (
<button
type='button'
className='mr-0.5 flex h-[14px] w-[14px] shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover-hover:text-[var(--text-secondary)]'
onClick={handleClearAll}
>
<span className='text-caption'></span>
</button>
) : null}
{search.dropdown && (
<div
ref={search.dropdownRef}
className='absolute top-full left-0 z-50 mt-1.5 w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
>
{search.dropdown}
</div>
)}
</div>
)
})
const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig }) {
const { options, active, onSort, onClear } = config
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='subtle' className='px-2 py-1 text-caption'>
<ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
{SORT_ICON}
Sort
</Button>
</DropdownMenuTrigger>
@@ -218,4 +255,4 @@ function SortDropdown({ config }: { config: SortConfig }) {
</DropdownMenuContent>
</DropdownMenu>
)
}
})

View File

@@ -8,6 +8,8 @@ import { ResourceHeader } from './components/resource-header'
import type { FilterTag, SearchConfig, SortConfig } from './components/resource-options-bar'
import { ResourceOptionsBar } from './components/resource-options-bar'
const CREATE_ROW_PLUS_ICON = <Plus className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
export interface ResourceColumn {
id: string
header: string
@@ -69,11 +71,13 @@ interface ResourceProps {
const EMPTY_CELL_PLACEHOLDER = '- - -'
const SKELETON_ROW_COUNT = 5
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation()
/**
* Shared page shell for resource list pages (tables, files, knowledge, schedules, logs).
* Renders the header, toolbar with search, and a data table from column/row definitions.
*/
export function Resource({
export const Resource = memo(function Resource({
icon,
title,
breadcrumbs,
@@ -135,7 +139,7 @@ export function Resource({
/>
</div>
)
}
})
export interface ResourceTableProps {
columns: ResourceColumn[]
@@ -229,6 +233,13 @@ export const ResourceTable = memo(function ResourceTable({
const hasCheckbox = selectable != null
const totalColSpan = columns.length + (hasCheckbox ? 1 : 0)
const handleSelectAll = useCallback(
(checked: boolean | 'indeterminate') => {
selectable?.onSelectAll(checked as boolean)
},
[selectable]
)
if (isLoading) {
return (
<DataTableSkeleton
@@ -259,7 +270,7 @@ export const ResourceTable = memo(function ResourceTable({
<Checkbox
size='sm'
checked={selectable.isAllSelected}
onCheckedChange={(checked) => selectable.onSelectAll(checked as boolean)}
onCheckedChange={handleSelectAll}
disabled={selectable.disabled}
aria-label='Select all'
/>
@@ -306,68 +317,20 @@ export const ResourceTable = memo(function ResourceTable({
<table className='w-full table-fixed text-small'>
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
<tbody>
{displayRows.map((row) => {
const isSelected = selectable?.selectedIds.has(row.id) ?? false
return (
<tr
key={row.id}
data-resource-row
data-row-id={row.id}
className={cn(
'transition-colors hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]'
)}
onClick={() => onRowClick?.(row.id)}
onMouseEnter={onRowHover ? () => onRowHover(row.id) : undefined}
onContextMenu={(e) => onRowContextMenu?.(e, row.id)}
>
{hasCheckbox && (
<td className='w-[52px] py-2.5 pr-0 pl-5 align-middle'>
<Checkbox
size='sm'
checked={isSelected}
onCheckedChange={(checked) =>
selectable.onSelectRow(row.id, checked as boolean)
}
disabled={selectable.disabled}
aria-label='Select row'
onClick={(e) => e.stopPropagation()}
/>
</td>
)}
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-6 py-2.5 align-middle'>
<CellContent
cell={{ ...cell, label: cell?.label || EMPTY_CELL_PLACEHOLDER }}
primary={colIdx === 0}
/>
</td>
)
})}
</tr>
)
})}
{create && (
<tr
className={cn(
'transition-colors',
create.disabled
? 'cursor-not-allowed'
: 'cursor-pointer hover-hover:bg-[var(--surface-3)]'
)}
onClick={create.disabled ? undefined : create.onClick}
>
<td colSpan={totalColSpan} className='px-6 py-2.5 align-middle'>
<span className='flex items-center gap-3 font-medium text-[var(--text-secondary)] text-sm'>
<Plus className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
{create.label}
</span>
</td>
</tr>
)}
{displayRows.map((row) => (
<DataRow
key={row.id}
row={row}
columns={columns}
selectedRowId={selectedRowId}
selectable={selectable}
onRowClick={onRowClick}
onRowHover={onRowHover}
onRowContextMenu={onRowContextMenu}
hasCheckbox={hasCheckbox}
/>
))}
{create && <CreateRow create={create} totalColSpan={totalColSpan} />}
</tbody>
</table>
{hasMore && (
@@ -390,7 +353,7 @@ export const ResourceTable = memo(function ResourceTable({
)
})
function Pagination({
const Pagination = memo(function Pagination({
currentPage,
totalPages,
onPageChange,
@@ -447,10 +410,17 @@ function Pagination({
</div>
</div>
)
})
interface CellContentProps {
icon?: ReactNode
label: string
content?: ReactNode
primary?: boolean
}
function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean }) {
if (cell.content) return <>{cell.content}</>
const CellContent = memo(function CellContent({ icon, label, content, primary }: CellContentProps) {
if (content) return <>{content}</>
return (
<span
className={cn(
@@ -458,19 +428,132 @@ function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean
primary ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]'
)}
>
{cell.icon && <span className='flex-shrink-0 text-[var(--text-icon)]'>{cell.icon}</span>}
<span className='truncate'>{cell.label}</span>
{icon && <span className='flex-shrink-0 text-[var(--text-icon)]'>{icon}</span>}
<span className='truncate'>{label}</span>
</span>
)
})
interface DataRowProps {
row: ResourceRow
columns: ResourceColumn[]
selectedRowId?: string | null
selectable?: SelectableConfig
onRowClick?: (rowId: string) => void
onRowHover?: (rowId: string) => void
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
hasCheckbox: boolean
}
function ResourceColGroup({
const DataRow = memo(function DataRow({
row,
columns,
selectedRowId,
selectable,
onRowClick,
onRowHover,
onRowContextMenu,
hasCheckbox,
}: {
}: DataRowProps) {
const isSelected = selectable?.selectedIds.has(row.id) ?? false
const handleClick = useCallback(() => {
onRowClick?.(row.id)
}, [onRowClick, row.id])
const handleMouseEnter = useCallback(() => {
onRowHover?.(row.id)
}, [onRowHover, row.id])
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
onRowContextMenu?.(e, row.id)
},
[onRowContextMenu, row.id]
)
const handleSelectRow = useCallback(
(checked: boolean | 'indeterminate') => {
selectable?.onSelectRow(row.id, checked as boolean)
},
[selectable, row.id]
)
return (
<tr
data-resource-row
data-row-id={row.id}
className={cn(
'transition-colors hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]'
)}
onClick={onRowClick ? handleClick : undefined}
onMouseEnter={handleMouseEnter}
onContextMenu={onRowContextMenu ? handleContextMenu : undefined}
>
{hasCheckbox && selectable && (
<td className='w-[52px] py-2.5 pr-0 pl-5 align-middle'>
<Checkbox
size='sm'
checked={isSelected}
onCheckedChange={handleSelectRow}
disabled={selectable.disabled}
aria-label='Select row'
onClick={stopPropagation}
/>
</td>
)}
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-6 py-2.5 align-middle'>
<CellContent
icon={cell?.icon}
label={cell?.label || EMPTY_CELL_PLACEHOLDER}
content={cell?.content}
primary={colIdx === 0}
/>
</td>
)
})}
</tr>
)
})
interface CreateRowProps {
create: CreateAction
totalColSpan: number
}
const CreateRow = memo(function CreateRow({ create, totalColSpan }: CreateRowProps) {
return (
<tr
className={cn(
'transition-colors',
create.disabled ? 'cursor-not-allowed' : 'cursor-pointer hover-hover:bg-[var(--surface-3)]'
)}
onClick={create.disabled ? undefined : create.onClick}
>
<td colSpan={totalColSpan} className='px-6 py-2.5 align-middle'>
<span className='flex items-center gap-3 font-medium text-[var(--text-secondary)] text-sm'>
{CREATE_ROW_PLUS_ICON}
{create.label}
</span>
</td>
</tr>
)
})
interface ResourceColGroupProps {
columns: ResourceColumn[]
hasCheckbox?: boolean
}) {
}
const ResourceColGroup = memo(function ResourceColGroup({
columns,
hasCheckbox,
}: ResourceColGroupProps) {
return (
<colgroup>
{hasCheckbox && <col className='w-[52px]' />}
@@ -486,17 +569,19 @@ function ResourceColGroup({
))}
</colgroup>
)
}
})
function DataTableSkeleton({
columns,
rowCount,
hasCheckbox,
}: {
interface DataTableSkeletonProps {
columns: ResourceColumn[]
rowCount: number
hasCheckbox?: boolean
}) {
}
const DataTableSkeleton = memo(function DataTableSkeleton({
columns,
rowCount,
hasCheckbox,
}: DataTableSkeletonProps) {
return (
<>
<div className='overflow-hidden'>
@@ -549,4 +634,4 @@ function DataTableSkeleton({
</div>
</>
)
}
})

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
@@ -183,6 +183,8 @@ function TextEditor({
} = useWorkspaceFileContent(workspaceId, file.id, file.key, file.type === 'text/x-pptxgenjs')
const updateContent = useUpdateWorkspaceFileContent()
const updateContentRef = useRef(updateContent)
updateContentRef.current = updateContent
const [content, setContent] = useState('')
const [savedContent, setSavedContent] = useState('')
@@ -230,14 +232,14 @@ function TextEditor({
const currentContent = contentRef.current
if (currentContent === savedContentRef.current) return
await updateContent.mutateAsync({
await updateContentRef.current.mutateAsync({
workspaceId,
fileId: file.id,
content: currentContent,
})
setSavedContent(currentContent)
savedContentRef.current = currentContent
}, [workspaceId, file.id, updateContent])
}, [workspaceId, file.id])
const { saveStatus, saveImmediately, isDirty } = useAutosave({
content,
@@ -402,7 +404,7 @@ function TextEditor({
)
}
function IframePreview({ file }: { file: WorkspaceFileRecord }) {
const IframePreview = memo(function IframePreview({ file }: { file: WorkspaceFileRecord }) {
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
return (
@@ -417,9 +419,9 @@ function IframePreview({ file }: { file: WorkspaceFileRecord }) {
/>
</div>
)
}
})
function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
return (
@@ -432,7 +434,7 @@ function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
/>
</div>
)
}
})
const pptxSlideCache = new Map<string, string[]>()
@@ -701,7 +703,11 @@ function PptxPreview({
)
}
function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) {
const UnsupportedPreview = memo(function UnsupportedPreview({
file,
}: {
file: WorkspaceFileRecord
}) {
const ext = getFileExtension(file.name)
return (
@@ -714,4 +720,4 @@ function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) {
</p>
</div>
)
}
})

View File

@@ -42,7 +42,12 @@ interface PreviewPanelProps {
isStreaming?: boolean
}
export function PreviewPanel({ content, mimeType, filename, isStreaming }: PreviewPanelProps) {
export const PreviewPanel = memo(function PreviewPanel({
content,
mimeType,
filename,
isStreaming,
}: PreviewPanelProps) {
const previewType = resolvePreviewType(mimeType, filename)
if (previewType === 'markdown')
@@ -52,7 +57,7 @@ export function PreviewPanel({ content, mimeType, filename, isStreaming }: Previ
if (previewType === 'svg') return <SvgPreview content={content} />
return null
}
})
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
@@ -197,7 +202,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
)
})
function HtmlPreview({ content }: { content: string }) {
const HtmlPreview = memo(function HtmlPreview({ content }: { content: string }) {
return (
<div className='h-full overflow-hidden'>
<iframe
@@ -208,9 +213,9 @@ function HtmlPreview({ content }: { content: string }) {
/>
</div>
)
}
})
function SvgPreview({ content }: { content: string }) {
const SvgPreview = memo(function SvgPreview({ content }: { content: string }) {
const wrappedContent = useMemo(
() =>
`<!DOCTYPE html><html><head><style>body{margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:transparent;}svg{max-width:100%;max-height:100vh;}</style></head><body>${content}</body></html>`,
@@ -227,9 +232,9 @@ function SvgPreview({ content }: { content: string }) {
/>
</div>
)
}
})
function CsvPreview({ content }: { content: string }) {
const CsvPreview = memo(function CsvPreview({ content }: { content: string }) {
const { headers, rows } = useMemo(() => parseCsv(content), [content])
if (headers.length === 0) {
@@ -271,7 +276,7 @@ function CsvPreview({ content }: { content: string }) {
</div>
</div>
)
}
})
function parseCsv(text: string): { headers: string[]; rows: string[][] } {
const lines = text.split('\n').filter((line) => line.trim().length > 0)

View File

@@ -1,5 +1,6 @@
'use client'
import { memo } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -18,7 +19,7 @@ interface FilesListContextMenuProps {
disableUpload?: boolean
}
export function FilesListContextMenu({
export const FilesListContextMenu = memo(function FilesListContextMenu({
isOpen,
position,
onClose,
@@ -64,4 +65,4 @@ export function FilesListContextMenu({
</DropdownMenuContent>
</DropdownMenu>
)
}
})

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import {
@@ -41,6 +41,7 @@ import type {
HeaderAction,
ResourceColumn,
ResourceRow,
SearchConfig,
} from '@/app/workspace/[workspaceId]/components'
import {
InlineRenameInput,
@@ -159,11 +160,29 @@ export function Files() {
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
const [searchTerm, setSearchTerm] = useState('')
const [inputValue, setInputValue] = useState('')
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('')
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
const handleSearchChange = useCallback((value: string) => {
setInputValue(value)
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
searchTimerRef.current = setTimeout(() => {
setDebouncedSearchTerm(value)
}, 200)
}, [])
const [creatingFile, setCreatingFile] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
const [previewMode, setPreviewMode] = useState<PreviewMode>(() => {
if (fileIdFromRoute) {
const file = files.find((f) => f.id === fileIdFromRoute)
if (file && isPreviewable(file)) return 'preview'
return 'editor'
}
return 'preview'
})
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [contextMenuFile, setContextMenuFile] = useState<WorkspaceFileRecord | null>(null)
@@ -183,59 +202,105 @@ export function Files() {
() => (fileIdFromRoute ? files.find((f) => f.id === fileIdFromRoute) : null),
[fileIdFromRoute, files]
)
const selectedFileRef = useRef(selectedFile)
selectedFileRef.current = selectedFile
const filteredFiles = useMemo(() => {
if (!searchTerm) return files
const q = searchTerm.toLowerCase()
if (!debouncedSearchTerm) return files
const q = debouncedSearchTerm.toLowerCase()
return files.filter((f) => f.name.toLowerCase().includes(q))
}, [files, searchTerm])
}, [files, debouncedSearchTerm])
const rows: ResourceRow[] = useMemo(
() =>
filteredFiles.map((file) => {
const Icon = getDocumentIcon(file.type || '', file.name)
return {
id: file.id,
cells: {
name: {
icon: <Icon className='h-[14px] w-[14px]' />,
label: file.name,
content:
listRename.editingId === file.id ? (
<span className='flex min-w-0 items-center gap-3 font-medium text-[var(--text-body)] text-sm'>
<span className='flex-shrink-0 text-[var(--text-icon)]'>
<Icon className='h-[14px] w-[14px]' />
</span>
<InlineRenameInput
value={listRename.editValue}
onChange={listRename.setEditValue}
onSubmit={listRename.submitRename}
onCancel={listRename.cancelRename}
/>
</span>
) : undefined,
},
size: {
label: formatFileSize(file.size, { includeBytes: true }),
},
type: {
icon: <Icon className='h-[14px] w-[14px]' />,
label: formatFileType(file.type, file.name),
},
created: timeCell(file.uploadedAt),
owner: ownerCell(file.uploadedBy, members),
updated: timeCell(file.uploadedAt),
},
sortValues: {
size: file.size,
created: -new Date(file.uploadedAt).getTime(),
updated: -new Date(file.uploadedAt).getTime(),
},
}
}),
[filteredFiles, members, listRename.editingId, listRename.editValue]
const rowCacheRef = useRef(
new Map<string, { row: ResourceRow; file: WorkspaceFileRecord; members: typeof members }>()
)
const baseRows: ResourceRow[] = useMemo(() => {
const prevCache = rowCacheRef.current
const nextCache = new Map<
string,
{ row: ResourceRow; file: WorkspaceFileRecord; members: typeof members }
>()
const result = filteredFiles.map((file) => {
const cached = prevCache.get(file.id)
if (cached && cached.file === file && cached.members === members) {
nextCache.set(file.id, cached)
return cached.row
}
const Icon = getDocumentIcon(file.type || '', file.name)
const row: ResourceRow = {
id: file.id,
cells: {
name: {
icon: <Icon className='h-[14px] w-[14px]' />,
label: file.name,
},
size: {
label: formatFileSize(file.size, { includeBytes: true }),
},
type: {
icon: <Icon className='h-[14px] w-[14px]' />,
label: formatFileType(file.type, file.name),
},
created: timeCell(file.uploadedAt),
owner: ownerCell(file.uploadedBy, members),
updated: timeCell(file.uploadedAt),
},
sortValues: {
size: file.size,
created: -new Date(file.uploadedAt).getTime(),
updated: -new Date(file.uploadedAt).getTime(),
},
}
nextCache.set(file.id, { row, file, members })
return row
})
rowCacheRef.current = nextCache
return result
}, [filteredFiles, members])
const rows: ResourceRow[] = useMemo(() => {
if (!listRename.editingId) return baseRows
return baseRows.map((row) => {
if (row.id !== listRename.editingId) return row
const file = filteredFiles.find((f) => f.id === row.id)
if (!file) return row
const Icon = getDocumentIcon(file.type || '', file.name)
return {
...row,
cells: {
...row.cells,
name: {
...row.cells.name,
content: (
<span className='flex min-w-0 items-center gap-3 font-medium text-[var(--text-body)] text-sm'>
<span className='flex-shrink-0 text-[var(--text-icon)]'>
<Icon className='h-[14px] w-[14px]' />
</span>
<InlineRenameInput
value={listRename.editValue}
onChange={listRename.setEditValue}
onSubmit={listRename.submitRename}
onCancel={listRename.cancelRename}
/>
</span>
),
},
},
}
})
}, [
baseRows,
listRename.editingId,
listRename.editValue,
listRename.setEditValue,
listRename.submitRename,
listRename.cancelRename,
filteredFiles,
])
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const list = e.target.files
@@ -288,8 +353,13 @@ export function Files() {
}
}, [])
const deleteTargetFileRef = useRef(deleteTargetFile)
deleteTargetFileRef.current = deleteTargetFile
const fileIdFromRouteRef = useRef(fileIdFromRoute)
fileIdFromRouteRef.current = fileIdFromRoute
const handleDelete = useCallback(async () => {
const target = deleteTargetFile
const target = deleteTargetFileRef.current
if (!target) return
try {
@@ -299,7 +369,7 @@ export function Files() {
})
setShowDeleteConfirm(false)
setDeleteTargetFile(null)
if (fileIdFromRoute === target.id) {
if (fileIdFromRouteRef.current === target.id) {
setIsDirty(false)
setSaveStatus('idle')
router.push(`/workspace/${workspaceId}/files`)
@@ -307,36 +377,44 @@ export function Files() {
} catch (err) {
logger.error('Failed to delete file:', err)
}
}, [deleteTargetFile, workspaceId, fileIdFromRoute, router])
}, [workspaceId, router])
const isDirtyRef = useRef(isDirty)
isDirtyRef.current = isDirty
const saveStatusRef = useRef(saveStatus)
saveStatusRef.current = saveStatus
const handleSave = useCallback(async () => {
if (!saveRef.current || !isDirty || saveStatus === 'saving') return
if (!saveRef.current || !isDirtyRef.current || saveStatusRef.current === 'saving') return
await saveRef.current()
}, [isDirty, saveStatus])
}, [])
const handleBackAttempt = useCallback(() => {
if (isDirty) {
if (isDirtyRef.current) {
setShowUnsavedChangesAlert(true)
} else {
setPreviewMode('editor')
router.push(`/workspace/${workspaceId}/files`)
}
}, [isDirty, router, workspaceId])
}, [router, workspaceId])
const handleStartHeaderRename = useCallback(() => {
if (selectedFile) headerRename.startRename(selectedFile.id, selectedFile.name)
}, [selectedFile, headerRename.startRename])
const file = selectedFileRef.current
if (file) headerRename.startRename(file.id, file.name)
}, [headerRename.startRename])
const handleDownloadSelected = useCallback(() => {
if (selectedFile) handleDownload(selectedFile)
}, [selectedFile, handleDownload])
const file = selectedFileRef.current
if (file) handleDownload(file)
}, [handleDownload])
const handleDeleteSelected = useCallback(() => {
if (selectedFile) {
setDeleteTargetFile(selectedFile)
const file = selectedFileRef.current
if (file) {
setDeleteTargetFile(file)
setShowDeleteConfirm(true)
}
}, [selectedFile])
}, [])
const fileDetailBreadcrumbs = useMemo(
() =>
@@ -379,9 +457,6 @@ export function Files() {
handleBackAttempt,
headerRename.editingId,
headerRename.editValue,
headerRename.setEditValue,
headerRename.submitRename,
headerRename.cancelRename,
handleStartHeaderRename,
handleDownloadSelected,
handleDeleteSelected,
@@ -396,12 +471,15 @@ export function Files() {
router.push(`/workspace/${workspaceId}/files`)
}, [router, workspaceId])
const creatingFileRef = useRef(creatingFile)
creatingFileRef.current = creatingFile
const handleCreateFile = useCallback(async () => {
if (creatingFile) return
if (creatingFileRef.current) return
setCreatingFile(true)
try {
const existingNames = new Set(files.map((f) => f.name))
const existingNames = new Set(filesRef.current.map((f) => f.name))
let name = 'untitled.md'
let counter = 1
while (existingNames.has(name)) {
@@ -423,42 +501,49 @@ export function Files() {
} finally {
setCreatingFile(false)
}
}, [creatingFile, files, workspaceId, router])
}, [workspaceId, router])
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
const file = files.find((f) => f.id === rowId)
const file = filesRef.current.find((f) => f.id === rowId)
if (file) {
setContextMenuFile(file)
openContextMenu(e)
}
},
[files, openContextMenu]
[openContextMenu]
)
const contextMenuFileRef = useRef(contextMenuFile)
contextMenuFileRef.current = contextMenuFile
const handleContextMenuOpen = useCallback(() => {
if (!contextMenuFile) return
router.push(`/workspace/${workspaceId}/files/${contextMenuFile.id}`)
const file = contextMenuFileRef.current
if (!file) return
router.push(`/workspace/${workspaceId}/files/${file.id}`)
closeContextMenu()
}, [contextMenuFile, closeContextMenu, router, workspaceId])
}, [closeContextMenu, router, workspaceId])
const handleContextMenuDownload = useCallback(() => {
if (!contextMenuFile) return
handleDownload(contextMenuFile)
const file = contextMenuFileRef.current
if (!file) return
handleDownload(file)
closeContextMenu()
}, [contextMenuFile, handleDownload, closeContextMenu])
}, [handleDownload, closeContextMenu])
const handleContextMenuRename = useCallback(() => {
if (contextMenuFile) listRename.startRename(contextMenuFile.id, contextMenuFile.name)
const file = contextMenuFileRef.current
if (file) listRename.startRename(file.id, file.name)
closeContextMenu()
}, [contextMenuFile, listRename.startRename, closeContextMenu])
}, [listRename.startRename, closeContextMenu])
const handleContextMenuDelete = useCallback(() => {
if (!contextMenuFile) return
setDeleteTargetFile(contextMenuFile)
const file = contextMenuFileRef.current
if (!file) return
setDeleteTargetFile(file)
setShowDeleteConfirm(true)
closeContextMenu()
}, [contextMenuFile, closeContextMenu])
}, [closeContextMenu])
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
@@ -479,41 +564,46 @@ export function Files() {
closeListContextMenu()
}, [closeListContextMenu])
useEffect(() => {
const prevFileIdRef = useRef(fileIdFromRoute)
if (fileIdFromRoute !== prevFileIdRef.current) {
prevFileIdRef.current = fileIdFromRoute
const isJustCreated =
fileIdFromRoute != null && justCreatedFileIdRef.current === fileIdFromRoute
if (justCreatedFileIdRef.current && !isJustCreated) {
justCreatedFileIdRef.current = null
}
if (isJustCreated) {
setPreviewMode('editor')
} else {
const file = fileIdFromRoute ? filesRef.current.find((f) => f.id === fileIdFromRoute) : null
const canPreview = file ? isPreviewable(file) : false
setPreviewMode(canPreview ? 'preview' : 'editor')
const nextMode: PreviewMode = isJustCreated
? 'editor'
: (() => {
const file = fileIdFromRoute
? filesRef.current.find((f) => f.id === fileIdFromRoute)
: null
return file && isPreviewable(file) ? 'preview' : 'editor'
})()
if (nextMode !== previewMode) {
setPreviewMode(nextMode)
}
}, [fileIdFromRoute])
}
useEffect(() => {
if (!selectedFile) return
const handleKeyDown = (e: KeyboardEvent) => {
if (!fileIdFromRouteRef.current) return
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
handleSave()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedFile, handleSave])
useEffect(() => {
if (!isDirty) return
const handler = (e: BeforeUnloadEvent) => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (!isDirtyRef.current) return
e.preventDefault()
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [isDirty])
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('beforeunload', handleBeforeUnload)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('beforeunload', handleBeforeUnload)
}
}, [handleSave])
const handleCyclePreviewMode = useCallback(() => {
setPreviewMode((prev) => {
@@ -592,27 +682,92 @@ export function Files() {
selectedFile,
saveStatus,
previewMode,
isDirty,
handleCyclePreviewMode,
handleTogglePreview,
handleSave,
isDirty,
handleDownloadSelected,
handleDeleteSelected,
])
/** Stable refs for values used in callbacks to avoid dependency churn */
const listRenameRef = useRef(listRename)
listRenameRef.current = listRename
const headerRenameRef = useRef(headerRename)
headerRenameRef.current = headerRename
const handleRowClick = useCallback(
(id: string) => {
if (listRenameRef.current.editingId !== id && !headerRenameRef.current.editingId) {
router.push(`/workspace/${workspaceId}/files/${id}`)
}
},
[router, workspaceId]
)
const handleUploadClick = useCallback(() => {
fileInputRef.current?.click()
}, [])
const canEdit = userPermissions.canEdit === true
const handleSearchClearAll = useCallback(() => {
handleSearchChange('')
}, [handleSearchChange])
const searchConfig: SearchConfig = useMemo(
() => ({
value: inputValue,
onChange: handleSearchChange,
onClearAll: handleSearchClearAll,
placeholder: 'Search files...',
}),
[inputValue, handleSearchChange, handleSearchClearAll]
)
const createConfig = useMemo(
() => ({
label: 'New file',
onClick: handleCreateFile,
disabled: uploading || creatingFile || !canEdit,
}),
[handleCreateFile, uploading, creatingFile, canEdit]
)
const uploadButtonLabel = useMemo(
() =>
uploading && uploadProgress.total > 0
? `${uploadProgress.completed}/${uploadProgress.total}`
: uploading
? 'Uploading...'
: 'Upload',
[uploading, uploadProgress.completed, uploadProgress.total]
)
const headerActionsConfig = useMemo(
() => [
{
label: uploadButtonLabel,
icon: Upload,
onClick: handleUploadClick,
},
],
[uploadButtonLabel, handleUploadClick]
)
const handleNavigateToFiles = useCallback(() => {
router.push(`/workspace/${workspaceId}/files`)
}, [router, workspaceId])
const loadingBreadcrumbs = useMemo(
() => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }],
[handleNavigateToFiles]
)
if (fileIdFromRoute && !selectedFile) {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
<ResourceHeader
icon={FilesIcon}
breadcrumbs={[
{
label: 'Files',
onClick: () => router.push(`/workspace/${workspaceId}/files`),
},
{ label: '...' },
]}
/>
<ResourceHeader icon={FilesIcon} breadcrumbs={loadingBreadcrumbs} />
<div className='flex flex-1 items-center justify-center'>
<Skeleton className='h-[16px] w-[200px]' />
</div>
@@ -633,7 +788,7 @@ export function Files() {
key={selectedFile.id}
file={selectedFile}
workspaceId={workspaceId}
canEdit={userPermissions.canEdit === true}
canEdit={canEdit}
previewMode={previewMode}
autoFocus={justCreatedFileIdRef.current === selectedFile.id}
onDirtyChange={setIsDirty}
@@ -672,43 +827,18 @@ export function Files() {
)
}
const uploadButtonLabel =
uploading && uploadProgress.total > 0
? `${uploadProgress.completed}/${uploadProgress.total}`
: uploading
? 'Uploading...'
: 'Upload'
return (
<>
<Resource
icon={FilesIcon}
title='Files'
create={{
label: 'New file',
onClick: handleCreateFile,
disabled: uploading || creatingFile || userPermissions.canEdit !== true,
}}
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search files...',
}}
create={createConfig}
search={searchConfig}
defaultSort='created'
headerActions={[
{
label: uploadButtonLabel,
icon: Upload,
onClick: () => fileInputRef.current?.click(),
},
]}
headerActions={headerActionsConfig}
columns={COLUMNS}
rows={rows}
onRowClick={(id) => {
if (listRename.editingId !== id && !headerRename.editingId) {
router.push(`/workspace/${workspaceId}/files/${id}`)
}
}}
onRowClick={handleRowClick}
onRowContextMenu={handleRowContextMenu}
isLoading={isLoading}
onContextMenu={handleContentContextMenu}
@@ -720,58 +850,20 @@ export function Files() {
onClose={closeListContextMenu}
onCreateFile={handleCreateFile}
onUploadFile={handleListUploadFile}
disableCreate={uploading || creatingFile || userPermissions.canEdit !== true}
disableUpload={uploading || userPermissions.canEdit !== true}
disableCreate={uploading || creatingFile || !canEdit}
disableUpload={uploading || !canEdit}
/>
<DropdownMenu
open={isContextMenuOpen}
onOpenChange={(open) => !open && closeContextMenu()}
modal={false}
>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onSelect={handleContextMenuOpen}>
<Eye />
Open
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleContextMenuDownload}>
<Download />
Download
</DropdownMenuItem>
{userPermissions.canEdit === true && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleContextMenuRename}>
<Pencil />
Rename
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleContextMenuDelete}>
<Trash />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<FileRowContextMenu
isOpen={isContextMenuOpen}
position={contextMenuPosition}
onClose={closeContextMenu}
onOpen={handleContextMenuOpen}
onDownload={handleContextMenuDownload}
onRename={handleContextMenuRename}
onDelete={handleContextMenuDelete}
canEdit={canEdit}
/>
<DeleteConfirmModal
open={showDeleteConfirm}
@@ -794,6 +886,75 @@ export function Files() {
)
}
interface FileRowContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onOpen: () => void
onDownload: () => void
onRename: () => void
onDelete: () => void
canEdit: boolean
}
const FileRowContextMenu = memo(function FileRowContextMenu({
isOpen,
position,
onClose,
onOpen,
onDownload,
onRename,
onDelete,
canEdit,
}: FileRowContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onSelect={onOpen}>
<Eye />
Open
</DropdownMenuItem>
<DropdownMenuItem onSelect={onDownload}>
<Download />
Download
</DropdownMenuItem>
{canEdit && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onRename}>
<Pencil />
Rename
</DropdownMenuItem>
<DropdownMenuItem onSelect={onDelete}>
<Trash />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
})
interface DeleteConfirmModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -802,7 +963,7 @@ interface DeleteConfirmModalProps {
isPending: boolean
}
function DeleteConfirmModal({
const DeleteConfirmModal = memo(function DeleteConfirmModal({
open,
onOpenChange,
fileName,
@@ -833,4 +994,4 @@ function DeleteConfirmModal({
</ModalContent>
</Modal>
)
}
})

View File

@@ -1138,9 +1138,12 @@ export function Document({
<span className='font-medium text-[var(--text-primary)]'>
{effectiveDocumentName}
</span>
? This will permanently delete the document and all {documentData?.chunkCount ?? 0}{' '}
chunk
{documentData?.chunkCount === 1 ? '' : 's'} within it.{' '}
?{' '}
<span className='text-[var(--text-error)]'>
This will permanently delete the document and all {documentData?.chunkCount ?? 0}{' '}
chunk
{documentData?.chunkCount === 1 ? '' : 's'} within it.
</span>{' '}
{documentData?.connectorId ? (
<span className='text-[var(--text-error)]'>
This document is synced from a connector. Deleting it will permanently exclude it

View File

@@ -1106,8 +1106,10 @@ export function KnowledgeBase({
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{knowledgeBaseName}</span>?
The knowledge base and all {pagination.total} document
{pagination.total === 1 ? '' : 's'} within it will be removed.{' '}
<span className='text-[var(--text-error)]'>
The knowledge base and all {pagination.total} document
{pagination.total === 1 ? '' : 's'} within it will be removed.
</span>{' '}
<span className='text-[var(--text-tertiary)]'>
You can restore it from Recently Deleted in Settings.
</span>
@@ -1147,7 +1149,9 @@ export function KnowledgeBase({
it from future syncs. To temporarily hide it from search, disable it instead.
</span>
) : (
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
<span className='text-[var(--text-error)]'>
This will permanently delete the document.
</span>
)}
</p>
)
@@ -1177,7 +1181,10 @@ export function KnowledgeBase({
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete {selectedDocuments.size} document
{selectedDocuments.size === 1 ? '' : 's'}?{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
<span className='text-[var(--text-error)]'>
This will permanently delete the selected document
{selectedDocuments.size === 1 ? '' : 's'}.
</span>
</p>
</ModalBody>
<ModalFooter>

View File

@@ -416,9 +416,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<ModalBody>
<div className='space-y-2'>
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete the "{selectedTag?.displayName}" tag? This will
remove this tag from {selectedTagUsage?.documentCount || 0} document
{selectedTagUsage?.documentCount !== 1 ? 's' : ''}.{' '}
Are you sure you want to delete the "{selectedTag?.displayName}" tag?{' '}
<span className='text-[var(--text-error)]'>
This will remove this tag from {selectedTagUsage?.documentCount || 0} document
{selectedTagUsage?.documentCount !== 1 ? 's' : ''}.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>

View File

@@ -73,12 +73,26 @@ export function ConnectorsSection({
isLoading,
canEdit,
}: ConnectorsSectionProps) {
const { mutate: triggerSync, isPending: isSyncing } = useTriggerSync()
const { mutate: updateConnector, isPending: isUpdating } = useUpdateConnector()
const { mutate: triggerSync } = useTriggerSync()
const { mutate: updateConnector } = useUpdateConnector()
const { mutate: deleteConnector, isPending: isDeleting } = useDeleteConnector()
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
const [editingConnector, setEditingConnector] = useState<ConnectorData | null>(null)
const [error, setError] = useState<string | null>(null)
const [syncingIds, setSyncingIds] = useState<Set<string>>(() => new Set())
const [updatingIds, setUpdatingIds] = useState<Set<string>>(() => new Set())
const addToSet = useCallback((setter: typeof setSyncingIds, id: string) => {
setter((prev) => new Set(prev).add(id))
}, [])
const removeFromSet = useCallback((setter: typeof setSyncingIds, id: string) => {
setter((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}, [])
const syncTriggeredAt = useRef<Record<string, number>>({})
const cooldownTimers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
@@ -103,6 +117,7 @@ export function ConnectorsSection({
if (isSyncOnCooldown(connectorId)) return
syncTriggeredAt.current[connectorId] = Date.now()
addToSet(setSyncingIds, connectorId)
triggerSync(
{ knowledgeBaseId, connectorId },
@@ -121,10 +136,35 @@ export function ConnectorsSection({
delete syncTriggeredAt.current[connectorId]
forceUpdate((n) => n + 1)
},
onSettled: () => removeFromSet(setSyncingIds, connectorId),
}
)
},
[knowledgeBaseId, triggerSync, isSyncOnCooldown]
[knowledgeBaseId, triggerSync, isSyncOnCooldown, addToSet, removeFromSet]
)
const handleTogglePause = useCallback(
(connector: ConnectorData) => {
addToSet(setUpdatingIds, connector.id)
updateConnector(
{
knowledgeBaseId,
connectorId: connector.id,
updates: {
status: connector.status === 'paused' ? 'active' : 'paused',
},
},
{
onSettled: () => removeFromSet(setUpdatingIds, connector.id),
onSuccess: () => setError(null),
onError: (err) => {
logger.error('Toggle pause failed', { error: err.message })
setError(err.message)
},
}
)
},
[knowledgeBaseId, updateConnector, addToSet, removeFromSet]
)
if (connectors.length === 0 && !canEdit && !isLoading) return null
@@ -163,28 +203,11 @@ export function ConnectorsSection({
workspaceId={workspaceId}
knowledgeBaseId={knowledgeBaseId}
canEdit={canEdit}
isSyncing={isSyncing}
isUpdating={isUpdating}
isSyncPending={syncingIds.has(connector.id)}
isUpdating={updatingIds.has(connector.id)}
syncCooldown={isSyncOnCooldown(connector.id)}
onSync={() => handleSync(connector.id)}
onTogglePause={() =>
updateConnector(
{
knowledgeBaseId,
connectorId: connector.id,
updates: {
status: connector.status === 'paused' ? 'active' : 'paused',
},
},
{
onSuccess: () => setError(null),
onError: (err) => {
logger.error('Toggle pause failed', { error: err.message })
setError(err.message)
},
}
)
}
onTogglePause={() => handleTogglePause(connector)}
onEdit={() => setEditingConnector(connector)}
onDelete={() => setDeleteTarget(connector.id)}
/>
@@ -206,8 +229,13 @@ export function ConnectorsSection({
<ModalHeader>Delete Connector</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)] text-sm'>
Are you sure you want to remove this connected source? Documents already synced will
remain in the knowledge base.
Are you sure you want to remove this connected source?{' '}
<span className='text-[var(--text-error)]'>
This will stop future syncs from this source.
</span>{' '}
<span className='text-[var(--text-tertiary)]'>
Documents already synced will remain in the knowledge base.
</span>
</p>
</ModalBody>
<ModalFooter>
@@ -250,7 +278,7 @@ interface ConnectorCardProps {
workspaceId: string
knowledgeBaseId: string
canEdit: boolean
isSyncing: boolean
isSyncPending: boolean
isUpdating: boolean
syncCooldown: boolean
onSync: () => void
@@ -264,7 +292,7 @@ function ConnectorCard({
workspaceId,
knowledgeBaseId,
canEdit,
isSyncing,
isSyncPending,
isUpdating,
syncCooldown,
onSync,
@@ -306,13 +334,13 @@ function ConnectorCard({
{Icon && <Icon className='h-5 w-5 flex-shrink-0' />}
<div className='flex flex-col gap-0.5'>
<div className='flex items-center gap-2'>
<span className='font-medium text-[var(--text-primary)] text-small'>
<span className='flex items-center gap-1.5 font-medium text-[var(--text-primary)] text-small'>
{connectorDef?.name || connector.connectorType}
{(isSyncPending || connector.status === 'syncing') && (
<Loader2 className='h-3 w-3 animate-spin text-[var(--text-muted)]' />
)}
</span>
<Badge variant={statusConfig.variant} className='text-micro'>
{connector.status === 'syncing' && (
<Loader2 className='mr-1 h-3 w-3 animate-spin' />
)}
{statusConfig.label}
</Badge>
</div>
@@ -356,7 +384,7 @@ function ConnectorCard({
variant='ghost'
className='h-7 w-7 p-0'
onClick={onSync}
disabled={connector.status === 'syncing' || isSyncing || syncCooldown}
disabled={connector.status === 'syncing' || isSyncPending || syncCooldown}
>
<RefreshCw
className={cn(

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { memo, useEffect, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { createLogger } from '@sim/logger'
import { Loader2, RotateCcw, X } from 'lucide-react'
@@ -78,7 +78,10 @@ interface SubmitStatus {
message: string
}
export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
export const CreateBaseModal = memo(function CreateBaseModal({
open,
onOpenChange,
}: CreateBaseModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -543,4 +546,4 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
</ModalContent>
</Modal>
)
}
})

View File

@@ -1,5 +1,6 @@
'use client'
import { memo } from 'react'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
interface DeleteKnowledgeBaseModalProps {
@@ -29,7 +30,7 @@ interface DeleteKnowledgeBaseModalProps {
* Delete confirmation modal for knowledge base items.
* Displays a warning message and confirmation buttons.
*/
export function DeleteKnowledgeBaseModal({
export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({
isOpen,
onClose,
onConfirm,
@@ -46,10 +47,17 @@ export function DeleteKnowledgeBaseModal({
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{knowledgeBaseName}</span>?
All associated documents, chunks, and embeddings will be removed.
<span className='text-[var(--text-error)]'>
All associated documents, chunks, and embeddings will be removed.
</span>
</>
) : (
'Are you sure you want to delete this knowledge base? All associated documents, chunks, and embeddings will be removed.'
<>
Are you sure you want to delete this knowledge base?{' '}
<span className='text-[var(--text-error)]'>
All associated documents, chunks, and embeddings will be removed.
</span>
</>
)}{' '}
<span className='text-[var(--text-tertiary)]'>
You can restore it from Recently Deleted in Settings.
@@ -67,4 +75,4 @@ export function DeleteKnowledgeBaseModal({
</ModalContent>
</Modal>
)
}
})

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { memo, useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { createLogger } from '@sim/logger'
import { useForm } from 'react-hook-form'
@@ -43,7 +43,7 @@ type FormValues = z.infer<typeof FormSchema>
/**
* Modal for editing knowledge base name and description
*/
export function EditKnowledgeBaseModal({
export const EditKnowledgeBaseModal = memo(function EditKnowledgeBaseModal({
open,
onOpenChange,
knowledgeBaseId,
@@ -172,4 +172,4 @@ export function EditKnowledgeBaseModal({
</ModalContent>
</Modal>
)
}
})

View File

@@ -1,5 +1,6 @@
'use client'
import { memo } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -30,7 +31,7 @@ interface KnowledgeBaseContextMenuProps {
* Context menu component for knowledge base cards.
* Displays open in new tab, view tags, edit, and delete options.
*/
export function KnowledgeBaseContextMenu({
export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
isOpen,
position,
onClose,
@@ -114,4 +115,4 @@ export function KnowledgeBaseContextMenu({
</DropdownMenuContent>
</DropdownMenu>
)
}
})

View File

@@ -1,5 +1,6 @@
'use client'
import { memo } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -20,7 +21,7 @@ interface KnowledgeListContextMenuProps {
* Context menu component for the knowledge base list page.
* Displays "Add knowledge base" option when right-clicking on empty space.
*/
export function KnowledgeListContextMenu({
export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({
isOpen,
position,
onClose,
@@ -58,4 +59,4 @@ export function KnowledgeListContextMenu({
</DropdownMenuContent>
</DropdownMenu>
)
}
})

View File

@@ -1,11 +1,18 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { Database } from '@/components/emcn/icons'
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
import type {
CreateAction,
ResourceCell,
ResourceColumn,
ResourceRow,
SearchConfig,
} from '@/app/workspace/[workspaceId]/components'
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import {
@@ -18,10 +25,10 @@ import {
import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
const logger = createLogger('Knowledge')
@@ -33,11 +40,48 @@ const COLUMNS: ResourceColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'documents', header: 'Documents' },
{ id: 'tokens', header: 'Tokens' },
{ id: 'connectors', header: 'Connectors' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
const DATABASE_ICON = <Database className='h-[14px] w-[14px]' />
function connectorCell(connectorTypes?: string[]): ResourceCell {
if (!connectorTypes || connectorTypes.length === 0) {
return { label: '—' }
}
const entries = connectorTypes
.map((type) => ({ type, def: CONNECTOR_REGISTRY[type] }))
.filter((e): e is { type: string; def: NonNullable<(typeof CONNECTOR_REGISTRY)[string]> } =>
Boolean(e.def?.icon)
)
if (entries.length === 0) return { label: '—' }
return {
content: (
<div className='flex items-center gap-1'>
{entries.map(({ type, def }) => {
const Icon = def.icon
return (
<Tooltip.Root key={type}>
<Tooltip.Trigger asChild>
<span className='flex-shrink-0'>
<Icon className='h-3.5 w-3.5' />
</span>
</Tooltip.Trigger>
<Tooltip.Content>{def.name}</Tooltip.Content>
</Tooltip.Root>
)
})}
</div>
),
}
}
export function Knowledge() {
const params = useParams()
const router = useRouter()
@@ -54,8 +98,22 @@ export function Knowledge() {
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const [searchInputValue, setSearchInputValue] = useState('')
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
const handleSearchChange = useCallback((value: string) => {
setSearchInputValue(value)
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
searchTimerRef.current = setTimeout(() => {
setDebouncedSearchQuery(value)
}, 300)
}, [])
const handleSearchClearAll = useCallback(() => {
handleSearchChange('')
}, [handleSearchChange])
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [activeKnowledgeBase, setActiveKnowledgeBase] = useState<KnowledgeBaseWithDocCount | null>(
@@ -69,7 +127,6 @@ export function Knowledge() {
const {
isOpen: isListContextMenuOpen,
position: listContextMenuPosition,
menuRef: listMenuRef,
handleContextMenu: handleListContextMenu,
closeMenu: closeListContextMenu,
} = useContextMenu()
@@ -77,11 +134,19 @@ export function Knowledge() {
const {
isOpen: isRowContextMenuOpen,
position: rowContextMenuPosition,
menuRef: rowMenuRef,
handleContextMenu: handleRowCtxMenu,
closeMenu: closeRowContextMenu,
} = useContextMenu()
const isRowContextMenuOpenRef = useRef(isRowContextMenuOpen)
isRowContextMenuOpenRef.current = isRowContextMenuOpen
const knowledgeBasesRef = useRef(knowledgeBases)
knowledgeBasesRef.current = knowledgeBases
const activeKnowledgeBaseRef = useRef(activeKnowledgeBase)
activeKnowledgeBaseRef.current = activeKnowledgeBase
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
@@ -96,7 +161,7 @@ export function Knowledge() {
[handleListContextMenu]
)
const handleAddKnowledgeBase = useCallback(() => {
const handleOpenCreateModal = useCallback(() => {
setIsCreateModalOpen(true)
}, [])
@@ -132,7 +197,7 @@ export function Knowledge() {
id: kb.id,
cells: {
name: {
icon: <Database className='h-[14px] w-[14px]' />,
icon: DATABASE_ICON,
label: kb.name,
},
documents: {
@@ -141,6 +206,7 @@ export function Knowledge() {
tokens: {
label: kb.tokenCount ? kb.tokenCount.toLocaleString() : '0',
},
connectors: connectorCell(kb.connectorTypes),
created: timeCell(kb.createdAt),
owner: ownerCell(kb.userId, members),
updated: timeCell(kb.updatedAt),
@@ -148,6 +214,7 @@ export function Knowledge() {
sortValues: {
documents: kbWithCount.docCount || 0,
tokens: kb.tokenCount || 0,
connectors: kb.connectorTypes?.length || 0,
created: -new Date(kb.createdAt).getTime(),
updated: -new Date(kb.updatedAt).getTime(),
},
@@ -158,51 +225,98 @@ export function Knowledge() {
const handleRowClick = useCallback(
(rowId: string) => {
if (isRowContextMenuOpen) return
const kb = knowledgeBases.find((k) => k.id === rowId)
if (isRowContextMenuOpenRef.current) return
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId)
if (!kb) return
const urlParams = new URLSearchParams({ kbName: kb.name })
router.push(`/workspace/${workspaceId}/knowledge/${rowId}?${urlParams.toString()}`)
},
[isRowContextMenuOpen, knowledgeBases, router, workspaceId]
[router, workspaceId]
)
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
const kb = knowledgeBases.find((k) => k.id === rowId) as KnowledgeBaseWithDocCount | undefined
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId) as
| KnowledgeBaseWithDocCount
| undefined
setActiveKnowledgeBase(kb ?? null)
handleRowCtxMenu(e)
},
[knowledgeBases, handleRowCtxMenu]
[handleRowCtxMenu]
)
const handleConfirmDelete = useCallback(async () => {
if (!activeKnowledgeBase) return
const kb = activeKnowledgeBaseRef.current
if (!kb) return
setIsDeleting(true)
try {
await handleDeleteKnowledgeBase(activeKnowledgeBase.id)
await handleDeleteKnowledgeBase(kb.id)
setIsDeleteModalOpen(false)
setActiveKnowledgeBase(null)
} finally {
setIsDeleting(false)
}
}, [activeKnowledgeBase, handleDeleteKnowledgeBase])
}, [handleDeleteKnowledgeBase])
const handleCloseDeleteModal = useCallback(() => {
setIsDeleteModalOpen(false)
setActiveKnowledgeBase(null)
}, [])
const handleOpenInNewTab = useCallback(() => {
const kb = activeKnowledgeBaseRef.current
if (!kb) return
const urlParams = new URLSearchParams({ kbName: kb.name })
window.open(`/workspace/${workspaceId}/knowledge/${kb.id}?${urlParams.toString()}`, '_blank')
}, [workspaceId])
const handleViewTags = useCallback(() => {
setIsTagsModalOpen(true)
}, [])
const handleCopyId = useCallback(() => {
const kb = activeKnowledgeBaseRef.current
if (kb) {
navigator.clipboard.writeText(kb.id)
}
}, [])
const handleEdit = useCallback(() => {
setIsEditModalOpen(true)
}, [])
const handleDelete = useCallback(() => {
setIsDeleteModalOpen(true)
}, [])
const canEdit = userPermissions.canEdit === true
const createAction: CreateAction = useMemo(
() => ({
label: 'New base',
onClick: handleOpenCreateModal,
disabled: !canEdit,
}),
[handleOpenCreateModal, canEdit]
)
const searchConfig: SearchConfig = useMemo(
() => ({
value: searchInputValue,
onChange: handleSearchChange,
onClearAll: handleSearchClearAll,
placeholder: 'Search knowledge bases...',
}),
[searchInputValue, handleSearchChange, handleSearchClearAll]
)
return (
<>
<Resource
icon={Database}
title='Knowledge Base'
create={{
label: 'New base',
onClick: () => setIsCreateModalOpen(true),
disabled: userPermissions.canEdit !== true,
}}
search={{
value: searchQuery,
onChange: setSearchQuery,
placeholder: 'Search knowledge bases...',
}}
create={createAction}
search={searchConfig}
defaultSort='created'
columns={COLUMNS}
rows={rows}
@@ -216,8 +330,8 @@ export function Knowledge() {
isOpen={isListContextMenuOpen}
position={listContextMenuPosition}
onClose={closeListContextMenu}
onAddKnowledgeBase={handleAddKnowledgeBase}
disableAdd={userPermissions.canEdit !== true}
onAddKnowledgeBase={handleOpenCreateModal}
disableAdd={!canEdit}
/>
{activeKnowledgeBase && (
@@ -225,23 +339,17 @@ export function Knowledge() {
isOpen={isRowContextMenuOpen}
position={rowContextMenuPosition}
onClose={closeRowContextMenu}
onOpenInNewTab={() => {
const urlParams = new URLSearchParams({ kbName: activeKnowledgeBase.name })
window.open(
`/workspace/${workspaceId}/knowledge/${activeKnowledgeBase.id}?${urlParams.toString()}`,
'_blank'
)
}}
onViewTags={() => setIsTagsModalOpen(true)}
onCopyId={() => navigator.clipboard.writeText(activeKnowledgeBase.id)}
onEdit={() => setIsEditModalOpen(true)}
onDelete={() => setIsDeleteModalOpen(true)}
onOpenInNewTab={handleOpenInNewTab}
onViewTags={handleViewTags}
onCopyId={handleCopyId}
onEdit={handleEdit}
onDelete={handleDelete}
showOpenInNewTab
showViewTags
showEdit
showDelete
disableEdit={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit}
disableEdit={!canEdit}
disableDelete={!canEdit}
/>
)}
@@ -259,10 +367,7 @@ export function Knowledge() {
{activeKnowledgeBase && (
<DeleteKnowledgeBaseModal
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false)
setActiveKnowledgeBase(null)
}}
onClose={handleCloseDeleteModal}
onConfirm={handleConfirmDelete}
isDeleting={isDeleting}
knowledgeBaseName={activeKnowledgeBase.name}

View File

@@ -1268,7 +1268,9 @@ export const NotificationSettings = memo(function NotificationSettings({
<ModalHeader>Delete Notification</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)] text-caption'>
This will permanently remove the notification and stop all deliveries.{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove the notification and stop all deliveries.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -371,8 +371,10 @@ export function ApiKeys() {
<ModalBody>
<p className='text-[var(--text-secondary)]'>
Deleting{' '}
<span className='font-medium text-[var(--text-primary)]'>{deleteKey?.name}</span> will
immediately revoke access for any integrations using it.{' '}
<span className='font-medium text-[var(--text-primary)]'>{deleteKey?.name}</span>{' '}
<span className='text-[var(--text-error)]'>
will immediately revoke access for any integrations using it.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -404,7 +404,10 @@ export function BYOK() {
<span className='font-medium text-[var(--text-primary)]'>
{PROVIDERS.find((p) => p.id === deleteConfirmProvider)?.name}
</span>{' '}
API key? This workspace will revert to using platform hosted keys.
API key?{' '}
<span className='text-[var(--text-error)]'>
This workspace will revert to using platform hosted keys.
</span>
</p>
</ModalBody>
<ModalFooter>

View File

@@ -366,7 +366,9 @@ export function Copilot() {
<span className='font-medium text-[var(--text-primary)]'>
{deleteKey?.name || 'Unnamed Key'}
</span>{' '}
will immediately revoke access for any integrations using it.{' '}
<span className='text-[var(--text-error)]'>
will immediately revoke access for any integrations using it.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -164,8 +164,10 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
)}
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
{isSingleRow ? 'this row' : `these ${deleteCount} rows`}? This will permanently remove
all data in {isSingleRow ? 'this row' : 'these rows'}.{' '}
{isSingleRow ? 'this row' : `these ${deleteCount} rows`}?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all data in {isSingleRow ? 'this row' : 'these rows'}.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -1809,6 +1809,9 @@ export function Table({
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{tableData?.name}</span>?{' '}
<span className='text-[var(--text-error)]'>
All {tableData?.rowCount ?? 0} rows will be removed.
</span>{' '}
<span className='text-[var(--text-tertiary)]'>
You can restore it from Recently Deleted in Settings.
</span>
@@ -1845,8 +1848,10 @@ export function Table({
<ModalBody>
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{deletingColumn}</span>? This
will remove all data in this column.{' '}
<span className='font-medium text-[var(--text-primary)]'>{deletingColumn}</span>?{' '}
<span className='text-[var(--text-error)]'>
This will remove all data in this column.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -320,8 +320,10 @@ export function Tables() {
<ModalBody>
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{activeTable?.name}</span>?
All {activeTable?.rowCount} rows will be removed.{' '}
<span className='font-medium text-[var(--text-primary)]'>{activeTable?.name}</span>?{' '}
<span className='text-[var(--text-error)]'>
All {activeTable?.rowCount} rows will be removed.
</span>{' '}
<span className='text-[var(--text-tertiary)]'>
You can restore it from Recently Deleted in Settings.
</span>

View File

@@ -929,7 +929,7 @@ export function Chat() {
>
{shouldShowConfigureStartInputsButton && (
<div
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-7)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]'
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-active)]'
title='Add chat inputs to Start block'
onMouseDown={(e) => {
e.stopPropagation()

View File

@@ -883,7 +883,7 @@ console.log(data);`
</p>
{missingFields.any && (
<div
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-[9px] py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-7)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]'
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-[9px] py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-active)]'
title='Add required A2A input fields to Start block'
onClick={handleAddA2AInputs}
>

View File

@@ -280,7 +280,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}
return (
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()}>
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()} colorScheme='inverted'>
<PopoverAnchor asChild>
<div
className={cn('pointer-events-none', className)}

View File

@@ -553,7 +553,7 @@ export function FileUpload({
return (
<div
key={fileKey}
className='relative rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 hover-hover:border-[var(--surface-7)] hover-hover:bg-[var(--surface-5)] dark:bg-[var(--surface-5)] dark:hover-hover:bg-[var(--border-1)]'
className='relative rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 hover-hover:bg-[var(--surface-active)] dark:bg-[var(--surface-5)]'
>
<div className='truncate pr-6 text-sm' title={file.name}>
<span className='text-[var(--text-primary)]'>{truncateMiddle(file.name)}</span>

View File

@@ -108,7 +108,7 @@ export function GroupedCheckboxList({
disabled={disabled}
className={cn(
'flex w-full cursor-pointer items-center justify-between rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 font-medium font-sans text-[var(--text-primary)] text-sm outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-[var(--surface-5)]',
'hover-hover:border-[var(--surface-7)] hover-hover:bg-[var(--surface-5)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]'
'hover-hover:bg-[var(--surface-active)]'
)}
>
<span className='flex flex-1 items-center gap-2 truncate text-[var(--text-muted)]'>

View File

@@ -1061,6 +1061,7 @@ try {
setShowSchemaParams(false)
}
}}
colorScheme='inverted'
>
<PopoverAnchor asChild>
<div
@@ -1178,8 +1179,11 @@ try {
<ModalHeader>Delete Custom Tool</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
This will permanently delete the tool and remove it from any workflows that are using
it. <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
<span className='text-[var(--text-error)]'>
This will permanently delete the tool and remove it from any workflows that are
using it.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>

View File

@@ -874,7 +874,10 @@ export const Panel = memo(function Panel() {
<span className='font-medium text-[var(--text-primary)]'>
{currentWorkflow?.name ?? 'this workflow'}
</span>
? All associated blocks, executions, and configuration will be removed.{' '}
?{' '}
<span className='text-[var(--text-error)]'>
All associated blocks, executions, and configuration will be removed.
</span>{' '}
<span className='text-[var(--text-tertiary)]'>
You can restore it from Recently Deleted in Settings.
</span>

View File

@@ -1822,7 +1822,7 @@ export function useWorkflowExecution() {
try {
const pointer = await loadExecutionPointer(reconnectWorkflowId)
if (cleanupRan) return
if (pointer && pointer.executionId) {
if (pointer?.executionId) {
executionId = pointer.executionId
fromEventId = pointer.lastEventId
}

View File

@@ -1,4 +1,4 @@
import type { MouseEvent as ReactMouseEvent } from 'react'
import { type MouseEvent as ReactMouseEvent, useState } from 'react'
import { Folder, MoreHorizontal, Plus } from 'lucide-react'
import Link from 'next/link'
import {
@@ -12,6 +12,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Pencil, SquareArrowUpRight } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
@@ -33,6 +34,7 @@ interface CollapsedSidebarMenuProps {
interface CollapsedTaskFlyoutItemProps {
task: { id: string; href: string; name: string; isActive?: boolean; isUnread?: boolean }
isCurrentRoute: boolean
isMenuOpen?: boolean
isEditing?: boolean
editValue?: string
inputRef?: React.RefObject<HTMLInputElement | null>
@@ -56,9 +58,9 @@ interface CollapsedWorkflowFlyoutItemProps {
onEditValueChange?: (value: string) => void
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
onEditBlur?: () => void
onContextMenu?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
onMorePointerDown?: () => void
onMoreClick?: (e: ReactMouseEvent<HTMLButtonElement>, workflow: WorkflowMetadata) => void
onOpenInNewTab?: () => void
onRename?: () => void
canRename?: boolean
}
const EDIT_ROW_CLASS =
@@ -68,10 +70,12 @@ function FlyoutMoreButton({
ariaLabel,
onPointerDown,
onClick,
isVisible,
}: {
ariaLabel: string
onPointerDown?: () => void
onClick: (e: ReactMouseEvent<HTMLButtonElement>) => void
isVisible?: boolean
}) {
return (
<button
@@ -79,7 +83,10 @@ function FlyoutMoreButton({
aria-label={ariaLabel}
onPointerDown={onPointerDown}
onClick={onClick}
className='-translate-y-1/2 absolute top-1/2 right-[8px] z-10 flex h-[18px] w-[18px] items-center justify-center rounded-[4px] opacity-0 transition-opacity hover:bg-[var(--surface-7)] focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100'
className={cn(
'-translate-y-1/2 absolute top-1/2 right-[8px] z-10 flex h-[18px] w-[18px] items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100',
isVisible && 'opacity-100'
)}
>
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</button>
@@ -154,7 +161,7 @@ export function CollapsedSidebarMenu({
<button
type='button'
aria-label={ariaLabel}
className='mx-0.5 flex h-[30px] items-center rounded-[8px] px-2 hover:bg-[var(--surface-active)]'
className='mx-0.5 flex h-[30px] items-center rounded-[8px] px-2 hover-hover:bg-[var(--surface-hover)]'
>
{icon}
</button>
@@ -180,6 +187,7 @@ export function CollapsedSidebarMenu({
export function CollapsedTaskFlyoutItem({
task,
isCurrentRoute,
isMenuOpen = false,
isEditing = false,
editValue,
inputRef,
@@ -221,12 +229,13 @@ export function CollapsedTaskFlyoutItem({
}
return (
<div className='group relative mx-0.5'>
<div className='group relative'>
<Link
href={task.href}
className={cn(
'flex min-h-[30px] min-w-0 items-center rounded-[5px] px-2 py-[5px] pr-[30px] font-medium text-[12px] text-[var(--text-body)] hover:bg-[var(--surface-active)] group-focus-within:bg-[var(--surface-active)] group-hover:bg-[var(--surface-active)]',
isCurrentRoute && 'bg-[var(--surface-active)]'
'flex min-w-0 cursor-default select-none items-center rounded-[5px] px-2 py-2 pr-[30px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors',
!(isCurrentRoute || isMenuOpen) && 'group-hover:bg-[var(--surface-hover)]',
(isCurrentRoute || isMenuOpen) && 'bg-[var(--surface-active)]'
)}
onContextMenu={
task.id !== 'new' && onContextMenu ? (e) => onContextMenu(e, task.id) : undefined
@@ -236,7 +245,9 @@ export function CollapsedTaskFlyoutItem({
title={task.name}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
statusIndicatorClassName={!isCurrentRoute ? 'group-hover:hidden' : undefined}
statusIndicatorClassName={
!(isCurrentRoute || isMenuOpen) ? 'group-hover:hidden' : undefined
}
/>
</Link>
{showActions && (
@@ -248,6 +259,7 @@ export function CollapsedTaskFlyoutItem({
e.stopPropagation()
onMoreClick?.(e, task.id)
}}
isVisible={isMenuOpen}
/>
)}
</div>
@@ -265,62 +277,102 @@ export function CollapsedWorkflowFlyoutItem({
onEditValueChange,
onEditKeyDown,
onEditBlur,
onContextMenu,
onMorePointerDown,
onMoreClick,
onOpenInNewTab,
onRename,
canRename = true,
}: CollapsedWorkflowFlyoutItemProps) {
const showActions = !!onMoreClick
const hasActions = !!onOpenInNewTab || !!onRename
const [actionsOpen, setActionsOpen] = useState(false)
if (isEditing) {
return (
<div className={EDIT_ROW_CLASS}>
<WorkflowColorSwatch color={workflow.color} />
<input
aria-label={`Rename workflow ${workflow.name}`}
ref={inputRef}
value={editValue ?? workflow.name}
onChange={(e) => onEditValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={onEditBlur}
className='w-full min-w-0 border-0 bg-transparent p-0 font-medium text-[12px] text-[var(--text-body)] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
disabled={isRenaming}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
<div className='group relative'>
<div className='flex min-w-0 cursor-default select-none items-center gap-2 rounded-[5px] bg-[var(--surface-active)] px-2 py-2 font-medium text-[var(--text-body)] text-caption outline-none'>
<WorkflowColorSwatch color={workflow.color} />
<input
aria-label={`Rename workflow ${workflow.name}`}
ref={inputRef}
value={editValue ?? workflow.name}
onChange={(e) => onEditValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={onEditBlur}
className='w-full min-w-0 border-0 bg-transparent p-0 font-medium text-[var(--text-body)] text-caption outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
disabled={isRenaming}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
</div>
</div>
)
}
return (
<div className='group relative mx-0.5'>
<div className='group relative'>
<Link
href={href}
className={cn(
'flex min-h-[30px] min-w-0 items-center gap-2 rounded-[5px] px-2 py-[5px] pr-[30px] font-medium text-[12px] text-[var(--text-body)] hover:bg-[var(--surface-active)] group-focus-within:bg-[var(--surface-active)] group-hover:bg-[var(--surface-active)]',
isCurrentRoute && 'bg-[var(--surface-active)]'
'flex min-w-0 cursor-default select-none items-center gap-2 rounded-[5px] px-2 py-2 pr-[30px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors',
!(isCurrentRoute || actionsOpen) && 'group-hover:bg-[var(--surface-hover)]',
(isCurrentRoute || actionsOpen) && 'bg-[var(--surface-active)]'
)}
onContextMenu={onContextMenu ? (e) => onContextMenu(e, workflow) : undefined}
onContextMenu={
hasActions
? (e) => {
e.preventDefault()
setActionsOpen(true)
}
: undefined
}
>
<WorkflowColorSwatch color={workflow.color} />
<span className='min-w-0 flex-1 truncate'>{workflow.name}</span>
</Link>
{showActions && (
<FlyoutMoreButton
ariaLabel='Workflow options'
onPointerDown={onMorePointerDown}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onMoreClick?.(e, workflow)
{hasActions && (
<DropdownMenuSub
open={actionsOpen}
onOpenChange={(open) => {
if (!open) setActionsOpen(false)
}}
/>
>
<DropdownMenuSubTrigger
aria-label='Workflow options'
className='-translate-y-1/2 absolute top-1/2 right-[8px] z-10 h-[18px] w-[18px] min-w-0 justify-center gap-0 rounded-sm p-0 opacity-0 transition-opacity focus:bg-transparent group-hover:opacity-100 data-[state=open]:bg-transparent data-[state=open]:opacity-100 [&>svg:last-child]:hidden [&_svg]:pointer-events-auto [&_svg]:size-[16px]'
onClick={(e) => {
e.stopPropagation()
setActionsOpen((prev) => !prev)
}}
>
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{onOpenInNewTab && (
<DropdownMenuItem onSelect={onOpenInNewTab}>
<SquareArrowUpRight className='h-[14px] w-[14px]' />
Open in new tab
</DropdownMenuItem>
)}
{onRename && (
<DropdownMenuItem
disabled={!canRename}
onSelect={(e) => {
e.preventDefault()
setActionsOpen(false)
onRename()
}}
>
<Pencil className='h-[14px] w-[14px]' />
Rename
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</div>
)
@@ -338,9 +390,9 @@ export function CollapsedFolderItems({
onEditValueChange,
onEditKeyDown,
onEditBlur,
onWorkflowContextMenu,
onWorkflowMorePointerDown,
onWorkflowMoreClick,
onWorkflowOpenInNewTab,
onWorkflowRename,
canRenameWorkflow,
}: {
nodes: FolderTreeNode[]
workflowsByFolder: Record<string, WorkflowMetadata[]>
@@ -353,9 +405,9 @@ export function CollapsedFolderItems({
onEditValueChange?: (value: string) => void
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
onEditBlur?: () => void
onWorkflowContextMenu?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
onWorkflowMorePointerDown?: () => void
onWorkflowMoreClick?: (e: ReactMouseEvent<HTMLButtonElement>, workflow: WorkflowMetadata) => void
onWorkflowOpenInNewTab?: (workflow: WorkflowMetadata) => void
onWorkflowRename?: (workflow: WorkflowMetadata) => void
canRenameWorkflow?: boolean
}) {
return (
<>
@@ -374,7 +426,7 @@ export function CollapsedFolderItems({
return (
<DropdownMenuSub key={folder.id}>
<DropdownMenuSubTrigger>
<DropdownMenuSubTrigger className='focus:bg-[var(--surface-hover)] data-[state=open]:bg-[var(--surface-hover)]'>
<Folder className='h-[14px] w-[14px]' />
<span className='truncate'>{folder.name}</span>
</DropdownMenuSubTrigger>
@@ -391,9 +443,9 @@ export function CollapsedFolderItems({
onEditValueChange={onEditValueChange}
onEditKeyDown={onEditKeyDown}
onEditBlur={onEditBlur}
onWorkflowContextMenu={onWorkflowContextMenu}
onWorkflowMorePointerDown={onWorkflowMorePointerDown}
onWorkflowMoreClick={onWorkflowMoreClick}
onWorkflowOpenInNewTab={onWorkflowOpenInNewTab}
onWorkflowRename={onWorkflowRename}
canRenameWorkflow={canRenameWorkflow}
/>
{folderWorkflows.map((workflow) => (
<CollapsedWorkflowFlyoutItem
@@ -408,9 +460,11 @@ export function CollapsedFolderItems({
onEditValueChange={onEditValueChange}
onEditKeyDown={onEditKeyDown}
onEditBlur={onEditBlur}
onContextMenu={onWorkflowContextMenu}
onMorePointerDown={onWorkflowMorePointerDown}
onMoreClick={onWorkflowMoreClick}
onOpenInNewTab={
onWorkflowOpenInNewTab ? () => onWorkflowOpenInNewTab(workflow) : undefined
}
onRename={onWorkflowRename ? () => onWorkflowRename(workflow) : undefined}
canRename={canRenameWorkflow}
/>
))}
</DropdownMenuSubContent>

View File

@@ -191,7 +191,7 @@ export function SettingsSidebar({
<button
type='button'
onClick={handleBack}
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm hover-hover:bg-[var(--surface-active)]'
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm hover-hover:bg-[var(--surface-hover)]'
>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'>
<ChevronDown className='h-[10px] w-[10px] rotate-90' />
@@ -259,7 +259,8 @@ export function SettingsSidebar({
const active = activeSection === item.id
const isLocked = item.requiresMax && !subscriptionAccess.hasUsableMaxAccess
const itemClassName = cn(
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px] hover:bg-[var(--surface-active)]',
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px]',
!active && 'hover-hover:bg-[var(--surface-hover)]',
active && 'bg-[var(--surface-active)]'
)
const content = (

View File

@@ -75,7 +75,10 @@ export function DeleteModal({
<span className='font-medium text-[var(--text-primary)]'>
{displayNames.join(', ')}
</span>
? All associated blocks, executions, and configuration will be removed.
?{' '}
<span className='text-[var(--text-error)]'>
All associated blocks, executions, and configuration will be removed.
</span>
</>
)
}
@@ -83,12 +86,21 @@ export function DeleteModal({
return (
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? All
associated blocks, executions, and configuration will be removed.
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>?{' '}
<span className='text-[var(--text-error)]'>
All associated blocks, executions, and configuration will be removed.
</span>
</>
)
}
return 'Are you sure you want to delete this workflow? All associated blocks, executions, and configuration will be removed.'
return (
<>
Are you sure you want to delete this workflow?{' '}
<span className='text-[var(--text-error)]'>
All associated blocks, executions, and configuration will be removed.
</span>
</>
)
}
if (itemType === 'folder') {
@@ -99,8 +111,11 @@ export function DeleteModal({
<span className='font-medium text-[var(--text-primary)]'>
{displayNames.join(', ')}
</span>
? This will permanently remove all workflows, logs, and knowledge bases within these
folders.
?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all workflows, logs, and knowledge bases within these
folders.
</span>
</>
)
}
@@ -108,12 +123,21 @@ export function DeleteModal({
return (
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? This
will permanently remove all associated workflows, logs, and knowledge bases.
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all associated workflows, logs, and knowledge bases.
</span>
</>
)
}
return 'Are you sure you want to delete this folder? This will permanently remove all associated workflows, logs, and knowledge bases.'
return (
<>
Are you sure you want to delete this folder?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all associated workflows, logs, and knowledge bases.
</span>
</>
)
}
if (itemType === 'task') {
@@ -124,7 +148,10 @@ export function DeleteModal({
<span className='font-medium text-[var(--text-primary)]'>
{displayNames.length} tasks
</span>
? This will permanently remove all conversation history.
?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all conversation history.
</span>
</>
)
}
@@ -132,12 +159,21 @@ export function DeleteModal({
return (
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? This
will permanently remove all conversation history.
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all conversation history.
</span>
</>
)
}
return 'Are you sure you want to delete this task? This will permanently remove all conversation history.'
return (
<>
Are you sure you want to delete this task?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all conversation history.
</span>
</>
)
}
if (itemType === 'mixed') {
@@ -148,12 +184,23 @@ export function DeleteModal({
<span className='font-medium text-[var(--text-primary)]'>
{displayNames.join(', ')}
</span>
? This will permanently remove all selected workflows and folders, including their
contents.
?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all selected workflows and folders, including their
contents.
</span>
</>
)
}
return 'Are you sure you want to delete the selected items? This will permanently remove all selected workflows and folders, including their contents.'
return (
<>
Are you sure you want to delete the selected items?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all selected workflows and folders, including their
contents.
</span>
</>
)
}
// workspace type
@@ -161,12 +208,22 @@ export function DeleteModal({
return (
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? This
will permanently remove all associated workflows, folders, logs, and knowledge bases.
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all associated workflows, folders, logs, and knowledge
bases.
</span>
</>
)
}
return 'Are you sure you want to delete this workspace? This will permanently remove all associated workflows, folders, logs, and knowledge bases.'
return (
<>
Are you sure you want to delete this workspace?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all associated workflows, folders, logs, and knowledge bases.
</span>
</>
)
}
return (

View File

@@ -448,8 +448,11 @@ export function FolderItem({
aria-label={`${folder.name} folder, ${isExpanded ? 'expanded' : 'collapsed'}`}
className={clsx(
'group mx-0.5 flex h-[30px] cursor-pointer items-center gap-2 rounded-lg px-2 text-sm',
!isAnyDragActive && 'hover-hover:bg-[var(--surface-active)]',
isSelected ? 'bg-[var(--surface-active)]' : '',
!isSelected &&
!isContextMenuOpen &&
!isAnyDragActive &&
'hover-hover:bg-[var(--surface-hover)]',
(isSelected || isContextMenuOpen) && 'bg-[var(--surface-active)]',
(isDragging || (isAnyDragActive && isSelected)) && 'opacity-50'
)}
onClick={handleClick}
@@ -511,8 +514,9 @@ export function FolderItem({
onPointerDown={handleMorePointerDown}
onClick={handleMoreClick}
className={clsx(
'flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity hover-hover:bg-[var(--surface-7)]',
!isAnyDragActive && 'group-hover:opacity-100'
'flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity',
!isAnyDragActive && 'group-hover:opacity-100',
isContextMenuOpen && 'opacity-100'
)}
>
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />

View File

@@ -386,8 +386,11 @@ export function WorkflowItem({
data-item-id={workflow.id}
className={clsx(
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm',
active && 'bg-[var(--surface-active)]',
!active && !isAnyDragActive && 'hover-hover:bg-[var(--surface-active)]',
(active || isContextMenuOpen) && 'bg-[var(--surface-active)]',
!active &&
!isContextMenuOpen &&
!isAnyDragActive &&
'hover-hover:bg-[var(--surface-hover)]',
isSelected && selectedWorkflows.size > 1 && !active && 'bg-[var(--surface-active)]',
(isDragging || (isAnyDragActive && isSelected)) && 'opacity-50'
)}
@@ -445,8 +448,9 @@ export function WorkflowItem({
onPointerDown={handleMorePointerDown}
onClick={handleMoreClick}
className={clsx(
'flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity hover-hover:bg-[var(--surface-7)]',
!isAnyDragActive && 'group-hover:opacity-100'
'flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity',
!isAnyDragActive && 'group-hover:opacity-100',
isContextMenuOpen && 'opacity-100'
)}
>
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />

View File

@@ -19,7 +19,8 @@ import {
Plus,
UserPlus,
} from '@/components/emcn'
import { getDisplayPlanName } from '@/lib/billing/plan-helpers'
import { getDisplayPlanName, isFree } from '@/lib/billing/plan-helpers'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
@@ -27,6 +28,7 @@ import { CreateWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
const logger = createLogger('WorkspaceHeader')
@@ -131,9 +133,17 @@ export function WorkspaceHeader({
}, [])
const { isInvitationsDisabled } = usePermissionConfig()
const { data: subscriptionResponse } = useSubscriptionData()
const rawPlanName = getDisplayPlanName(subscriptionResponse?.data?.plan)
const planDisplayName = rawPlanName.includes('for Teams') ? rawPlanName : `${rawPlanName} Plan`
const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
const { navigateToSettings } = useSettingsNavigation()
const currentPlan = subscriptionResponse?.data?.plan
const showPlanInfo = isBillingEnabled && typeof currentPlan !== 'undefined'
const rawPlanName = showPlanInfo ? getDisplayPlanName(currentPlan) : ''
const planDisplayName = showPlanInfo
? rawPlanName.includes('for Teams')
? rawPlanName
: `${rawPlanName} Plan`
: ''
const isFreePlan = showPlanInfo && isFree(currentPlan)
// Listen for open-invite-modal event from context menu
useEffect(() => {
@@ -395,11 +405,30 @@ export function WorkspaceHeader({
>
{workspaceInitial}
</div>
<div className='flex min-w-0 flex-col'>
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate font-medium text-[var(--text-primary)] text-small'>
{activeWorkspace?.name || 'Loading...'}
</span>
<span className='text-[var(--text-tertiary)] text-xs'>{planDisplayName}</span>
{showPlanInfo && (
<div className='flex items-center gap-2'>
<span className='truncate text-[var(--text-tertiary)] text-xs'>
{planDisplayName}
</span>
{isFreePlan && (
<button
type='button'
className='flex-shrink-0 rounded-full bg-[color-mix(in_srgb,var(--brand-accent)_16%,transparent)] px-2 py-0.5 font-medium text-[11px] text-[var(--brand-accent)] leading-none transition-opacity hover:opacity-85'
onClick={(e) => {
e.stopPropagation()
setIsWorkspaceMenuOpen(false)
navigateToSettings({ section: 'subscription' })
}}
>
Upgrade
</button>
)}
</div>
)}
</div>
</div>
@@ -463,7 +492,9 @@ export function WorkspaceHeader({
) : (
<div
className={cn(
'group flex cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]',
'group flex cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors',
workspace.id !== workspaceId &&
'hover-hover:bg-[var(--surface-hover)]',
workspace.id === workspaceId && 'bg-[var(--surface-active)]'
)}
onClick={() => onWorkspaceSwitch(workspace)}
@@ -482,7 +513,7 @@ export function WorkspaceHeader({
const rect = e.currentTarget.getBoundingClientRect()
openContextMenuAt(workspace, rect.right, rect.top)
}}
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity hover-hover:bg-[var(--surface-7)] group-hover:opacity-100'
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100'
>
<MoreHorizontal className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
</button>
@@ -496,7 +527,7 @@ export function WorkspaceHeader({
<div className='mt-1 flex flex-col gap-0.5'>
<button
type='button'
className='flex w-full cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)] disabled:pointer-events-none disabled:opacity-50'
className='flex w-full cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-hover)] disabled:pointer-events-none disabled:opacity-50'
onClick={(e) => {
e.stopPropagation()
setIsWorkspaceMenuOpen(false)
@@ -514,7 +545,7 @@ export function WorkspaceHeader({
<DropdownMenuSeparator />
<button
type='button'
className='flex w-full cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]'
className='flex w-full cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-hover)]'
onClick={() => {
setIsInviteModalOpen(true)
setIsWorkspaceMenuOpen(false)

View File

@@ -113,6 +113,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
isSelected,
isActive,
isUnread,
isMenuOpen,
showCollapsedTooltips,
onMultiSelectClick,
onContextMenu,
@@ -124,6 +125,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
isSelected: boolean
isActive: boolean
isUnread: boolean
isMenuOpen: boolean
showCollapsedTooltips: boolean
onMultiSelectClick: (taskId: string, shiftKey: boolean, metaKey: boolean) => void
onContextMenu: (e: React.MouseEvent, taskId: string) => void
@@ -136,8 +138,10 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
<Link
href={task.href}
className={cn(
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm hover-hover:bg-[var(--surface-active)]',
(isCurrentRoute || isSelected) && 'bg-[var(--surface-active)]'
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm',
!(isCurrentRoute || isSelected || isMenuOpen) &&
'hover-hover:bg-[var(--surface-hover)]',
(isCurrentRoute || isSelected || isMenuOpen) && 'bg-[var(--surface-active)]'
)}
onClick={(e) => {
if (task.id === 'new') return
@@ -177,7 +181,10 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
e.stopPropagation()
onMoreClick(e, task.id)
}}
className='flex h-[18px] w-[18px] items-center justify-center rounded-sm opacity-0 hover-hover:bg-[var(--surface-7)] group-hover:opacity-100'
className={cn(
'flex h-[18px] w-[18px] items-center justify-center rounded-sm opacity-0 group-hover:opacity-100',
isMenuOpen && 'opacity-100'
)}
>
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</button>
@@ -214,8 +221,8 @@ const SidebarNavItem = memo(function SidebarNavItem({
onContextMenu?: (e: React.MouseEvent, href: string) => void
}) {
const Icon = item.icon
const baseClasses =
'group flex h-[30px] items-center gap-2 rounded-lg mx-0.5 px-2 text-sm hover-hover:bg-[var(--surface-active)]'
const baseClasses = 'group flex h-[30px] items-center gap-2 rounded-lg mx-0.5 px-2 text-sm'
const hoverClasses = !active ? 'hover-hover:bg-[var(--surface-hover)]' : ''
const activeClasses = active ? 'bg-[var(--surface-active)]' : ''
const content = (
@@ -230,7 +237,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
href={item.href}
data-item-id={item.id}
data-tour={`nav-${item.id}`}
className={`${baseClasses} ${activeClasses}`}
className={`${baseClasses} ${hoverClasses} ${activeClasses}`}
onClick={
item.onClick
? (e) => {
@@ -249,7 +256,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
type='button'
data-item-id={item.id}
data-tour={`nav-${item.id}`}
className={`${baseClasses} ${activeClasses}`}
className={`${baseClasses} ${hoverClasses} ${activeClasses}`}
onClick={item.onClick}
>
{content}
@@ -310,6 +317,11 @@ export const Sidebar = memo(function Sidebar() {
const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
const isOnWorkflowPage = !!workflowId
const isCollapsedRef = useRef(isCollapsed)
useLayoutEffect(() => {
isCollapsedRef.current = isCollapsed
}, [isCollapsed])
// Delay collapsed tooltips until the width transition finishes.
const [showCollapsedTooltips, setShowCollapsedTooltips] = useState(isCollapsed)
@@ -485,6 +497,11 @@ export const Sidebar = memo(function Sidebar() {
taskIds: [],
names: [],
})
const [menuOpenTaskId, setMenuOpenTaskId] = useState<string | null>(null)
useEffect(() => {
if (!isTaskContextMenuOpen) setMenuOpenTaskId(null)
}, [isTaskContextMenuOpen])
const captureTaskSelection = useCallback((taskId: string) => {
const { selectedTasks, selectTaskOnly } = useFolderStore.getState()
@@ -502,6 +519,7 @@ export const Sidebar = memo(function Sidebar() {
const handleTaskContextMenu = useCallback(
(e: React.MouseEvent, taskId: string) => {
captureTaskSelection(taskId)
setMenuOpenTaskId(taskId)
tasksHover.setLocked(true)
preventTaskDismiss()
handleTaskContextMenuBase(e)
@@ -523,6 +541,7 @@ export const Sidebar = memo(function Sidebar() {
}
tasksHover.setLocked(true)
captureTaskSelection(taskId)
setMenuOpenTaskId(taskId)
const rect = e.currentTarget.getBoundingClientRect()
handleTaskContextMenuBase({
preventDefault: () => {},
@@ -540,77 +559,6 @@ export const Sidebar = memo(function Sidebar() {
]
)
const {
isOpen: isCollapsedWorkflowContextMenuOpen,
position: collapsedWorkflowContextMenuPosition,
menuRef: collapsedWorkflowMenuRef,
handleContextMenu: handleCollapsedWorkflowContextMenuBase,
closeMenu: closeCollapsedWorkflowContextMenu,
preventDismiss: preventCollapsedWorkflowDismiss,
} = useContextMenu()
const collapsedWorkflowContextMenuRef = useRef<{
workflowId: string
workflowName: string
} | null>(null)
const captureCollapsedWorkflowSelection = useCallback(
(workflow: { id: string; name: string }) => {
collapsedWorkflowContextMenuRef.current = {
workflowId: workflow.id,
workflowName: workflow.name,
}
},
[]
)
const handleCollapsedWorkflowContextMenu = useCallback(
(e: React.MouseEvent, workflow: { id: string; name: string }) => {
captureCollapsedWorkflowSelection(workflow)
workflowsHover.setLocked(true)
preventCollapsedWorkflowDismiss()
handleCollapsedWorkflowContextMenuBase(e)
},
[
captureCollapsedWorkflowSelection,
handleCollapsedWorkflowContextMenuBase,
preventCollapsedWorkflowDismiss,
workflowsHover,
]
)
const handleCollapsedWorkflowMorePointerDown = useCallback(() => {
if (isCollapsedWorkflowContextMenuOpen) {
preventCollapsedWorkflowDismiss()
}
}, [isCollapsedWorkflowContextMenuOpen, preventCollapsedWorkflowDismiss])
const handleCollapsedWorkflowMoreClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>, workflow: { id: string; name: string }) => {
if (isCollapsedWorkflowContextMenuOpen) {
closeCollapsedWorkflowContextMenu()
return
}
workflowsHover.setLocked(true)
captureCollapsedWorkflowSelection(workflow)
const rect = e.currentTarget.getBoundingClientRect()
handleCollapsedWorkflowContextMenuBase({
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
clientY: rect.top,
} as React.MouseEvent)
},
[
isCollapsedWorkflowContextMenuOpen,
closeCollapsedWorkflowContextMenu,
captureCollapsedWorkflowSelection,
handleCollapsedWorkflowContextMenuBase,
workflowsHover,
]
)
const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({
workspaceId,
})
@@ -709,14 +657,14 @@ export const Sidebar = memo(function Sidebar() {
icon: Settings,
href: getSettingsHref(),
onClick: () => {
if (!isCollapsed) {
if (!isCollapsedRef.current) {
setSidebarWidth(SIDEBAR_WIDTH.MIN)
}
navigateToSettings()
},
},
],
[workspaceId, navigateToSettings, getSettingsHref, isCollapsed, setSidebarWidth]
[navigateToSettings, getSettingsHref, setSidebarWidth]
)
const handleStartTour = useCallback(() => {
@@ -810,12 +758,12 @@ export const Sidebar = memo(function Sidebar() {
const navigateToPage = useCallback(
(path: string) => {
if (!isCollapsed) {
if (!isCollapsedRef.current) {
setSidebarWidth(SIDEBAR_WIDTH.MIN)
}
router.push(path)
},
[isCollapsed, setSidebarWidth, router]
[setSidebarWidth, router]
)
const handleConfirmDeleteTasks = useCallback(() => {
@@ -854,10 +802,6 @@ export const Sidebar = memo(function Sidebar() {
itemType: 'workflow',
onSave: async (workflowIdToRename, name) => {
await updateWorkflow(workflowIdToRename, { name })
collapsedWorkflowContextMenuRef.current = {
workflowId: workflowIdToRename,
workflowName: name,
}
},
})
@@ -866,8 +810,8 @@ export const Sidebar = memo(function Sidebar() {
}, [isTaskContextMenuOpen, taskFlyoutRename.editingId, tasksHover.setLocked])
useEffect(() => {
workflowsHover.setLocked(isCollapsedWorkflowContextMenuOpen || !!workflowFlyoutRename.editingId)
}, [isCollapsedWorkflowContextMenuOpen, workflowFlyoutRename.editingId, workflowsHover.setLocked])
workflowsHover.setLocked(!!workflowFlyoutRename.editingId)
}, [workflowFlyoutRename.editingId, workflowsHover.setLocked])
const handleTaskOpenInNewTab = useCallback(() => {
const { taskIds: ids } = contextMenuSelectionRef.current
@@ -897,22 +841,20 @@ export const Sidebar = memo(function Sidebar() {
taskFlyoutRename.startRename({ id: taskId, name: task.name })
}, [taskFlyoutRename, tasks, tasksHover])
const handleCollapsedWorkflowOpenInNewTab = useCallback(() => {
const workflow = collapsedWorkflowContextMenuRef.current
if (!workflow) return
window.open(
`/workspace/${workspaceId}/w/${workflow.workflowId}`,
'_blank',
'noopener,noreferrer'
)
}, [workspaceId])
const handleCollapsedWorkflowOpenInNewTab = useCallback(
(workflow: { id: string }) => {
window.open(`/workspace/${workspaceId}/w/${workflow.id}`, '_blank', 'noopener,noreferrer')
},
[workspaceId]
)
const handleStartCollapsedWorkflowRename = useCallback(() => {
const workflow = collapsedWorkflowContextMenuRef.current
if (!workflow) return
workflowsHover.setLocked(true)
workflowFlyoutRename.startRename({ id: workflow.workflowId, name: workflow.workflowName })
}, [workflowFlyoutRename, workflowsHover])
const handleCollapsedWorkflowRename = useCallback(
(workflow: { id: string; name: string }) => {
workflowsHover.setLocked(true)
workflowFlyoutRename.startRename({ id: workflow.id, name: workflow.name })
},
[workflowFlyoutRename, workflowsHover]
)
const [hasOverflowTop, setHasOverflowTop] = useState(false)
const [hasOverflowBottom, setHasOverflowBottom] = useState(false)
@@ -1064,6 +1006,88 @@ export const Sidebar = memo(function Sidebar() {
[importWorkspace]
)
// ── Memoised elements & objects for collapsed menus ──
// Prevents new JSX/object references on every render, which would defeat
// React.memo on CollapsedSidebarMenu and its children.
const tasksCollapsedIcon = useMemo(
() => <Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />,
[]
)
const workflowIconStyle = useMemo<React.CSSProperties>(
() => ({
backgroundColor: 'var(--text-icon)',
borderColor: 'color-mix(in srgb, var(--text-icon) 60%, transparent)',
backgroundClip: 'padding-box',
}),
[]
)
const workflowsCollapsedIcon = useMemo(
() => (
<div
className='h-[16px] w-[16px] flex-shrink-0 rounded-[3px] border-[2px]'
style={workflowIconStyle}
/>
),
[workflowIconStyle]
)
const tasksPrimaryAction = useMemo(
() => ({
label: 'New task',
onSelect: () => navigateToPage(`/workspace/${workspaceId}/home`),
}),
[navigateToPage, workspaceId]
)
const workflowsPrimaryAction = useMemo(
() => ({
label: 'New workflow',
onSelect: handleCreateWorkflow,
}),
[handleCreateWorkflow]
)
// Stable no-op for collapsed workflow context menu delete (never changes)
const noop = useCallback(() => {}, [])
// Stable callback for the "New task" button in expanded mode
const handleNewTask = useCallback(
() => navigateToPage(`/workspace/${workspaceId}/home`),
[navigateToPage, workspaceId]
)
// Stable callback for "See more" tasks
const handleSeeMoreTasks = useCallback(() => setVisibleTaskCount((prev) => prev + 5), [])
// Stable callback for DeleteModal close
const handleCloseTaskDeleteModal = useCallback(() => setIsTaskDeleteModalOpen(false), [])
// Stable handler for help modal open from dropdown
const handleOpenHelpFromMenu = useCallback(() => setIsHelpModalOpen(true), [])
// Stable handler for opening docs
const handleOpenDocs = useCallback(
() => window.open('https://docs.sim.ai', '_blank', 'noopener,noreferrer'),
[]
)
// Stable blur handlers for inline rename inputs
const handleTaskRenameBlur = useCallback(
() => void taskFlyoutRename.saveRename(),
[taskFlyoutRename.saveRename]
)
const handleWorkflowRenameBlur = useCallback(
() => void workflowFlyoutRename.saveRename(),
[workflowFlyoutRename.saveRename]
)
// Stable style for hidden file inputs
const hiddenStyle = useMemo(() => ({ display: 'none' }) as const, [])
const resolveWorkspaceIdFromPath = useCallback((): string | undefined => {
if (workspaceId) return workspaceId
if (typeof window === 'undefined') return undefined
@@ -1150,7 +1174,7 @@ export const Sidebar = memo(function Sidebar() {
<div className='relative flex h-[30px] items-center'>
<Link
href={`/workspace/${workspaceId}/home`}
className='sidebar-collapse-hide sidebar-collapse-remove flex h-[30px] items-center rounded-[8px] px-1.5 hover:bg-[var(--surface-active)]'
className='sidebar-collapse-hide sidebar-collapse-remove flex h-[30px] items-center rounded-[8px] px-1.5 hover-hover:bg-[var(--surface-hover)]'
tabIndex={isCollapsed ? -1 : 0}
>
{brand.logoUrl ? (
@@ -1172,7 +1196,7 @@ export const Sidebar = memo(function Sidebar() {
<button
type='button'
onClick={toggleCollapsed}
className='sidebar-collapse-show group absolute left-0 flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover:bg-[var(--surface-active)]'
className='sidebar-collapse-show group absolute left-0 flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover-hover:bg-[var(--surface-hover)]'
aria-label='Expand sidebar'
tabIndex={isCollapsed ? 0 : -1}
>
@@ -1204,7 +1228,7 @@ export const Sidebar = memo(function Sidebar() {
type='button'
onClick={toggleCollapsed}
className={cn(
'sidebar-collapse-btn ml-auto flex h-[30px] items-center justify-center overflow-hidden rounded-lg transition-all duration-200 hover-hover:bg-[var(--surface-active)]',
'sidebar-collapse-btn ml-auto flex h-[30px] items-center justify-center overflow-hidden rounded-lg transition-all duration-200 hover-hover:bg-[var(--surface-hover)]',
isCollapsed ? 'w-0 opacity-0' : 'w-[30px] opacity-100'
)}
aria-label='Collapse sidebar'
@@ -1256,7 +1280,7 @@ export const Sidebar = memo(function Sidebar() {
<div className='mt-2.5 flex flex-shrink-0 flex-col gap-0.5 px-2'>
{topNavItems.map((item) => (
<SidebarNavItem
key={`${item.id}-${isCollapsed}`}
key={item.id}
item={item}
active={item.href ? !!pathname?.startsWith(item.href) : false}
showCollapsedTooltips={showCollapsedTooltips}
@@ -1273,7 +1297,7 @@ export const Sidebar = memo(function Sidebar() {
<div className='flex flex-col gap-0.5 px-2'>
{workspaceNavItems.map((item) => (
<SidebarNavItem
key={`${item.id}-${isCollapsed}`}
key={item.id}
item={item}
active={item.href ? !!pathname?.startsWith(item.href) : false}
showCollapsedTooltips={showCollapsedTooltips}
@@ -1301,8 +1325,8 @@ export const Sidebar = memo(function Sidebar() {
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-active)]'
onClick={() => navigateToPage(`/workspace/${workspaceId}/home`)}
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleNewTask}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
@@ -1316,16 +1340,11 @@ export const Sidebar = memo(function Sidebar() {
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
}
icon={tasksCollapsedIcon}
hover={tasksHover}
ariaLabel='Tasks'
className='mt-1.5'
primaryAction={{
label: 'New task',
onSelect: () => navigateToPage(`/workspace/${workspaceId}/home`),
}}
primaryAction={tasksPrimaryAction}
>
{tasksLoading ? (
<DropdownMenuItem disabled>
@@ -1338,13 +1357,14 @@ export const Sidebar = memo(function Sidebar() {
key={task.id}
task={task}
isCurrentRoute={task.id !== 'new' && pathname === task.href}
isMenuOpen={menuOpenTaskId === task.id}
isEditing={task.id === taskFlyoutRename.editingId}
editValue={taskFlyoutRename.value}
inputRef={taskFlyoutRename.inputRef}
isRenaming={taskFlyoutRename.isSaving}
onEditValueChange={taskFlyoutRename.setValue}
onEditKeyDown={taskFlyoutRename.handleKeyDown}
onEditBlur={() => void taskFlyoutRename.saveRename()}
onEditBlur={handleTaskRenameBlur}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
@@ -1375,7 +1395,7 @@ export const Sidebar = memo(function Sidebar() {
value={taskFlyoutRename.value}
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
onKeyDown={taskFlyoutRename.handleKeyDown}
onBlur={() => void taskFlyoutRename.saveRename()}
onBlur={handleTaskRenameBlur}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
@@ -1390,6 +1410,7 @@ export const Sidebar = memo(function Sidebar() {
isSelected={isSelected}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
isMenuOpen={menuOpenTaskId === task.id}
showCollapsedTooltips={showCollapsedTooltips}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
@@ -1401,8 +1422,8 @@ export const Sidebar = memo(function Sidebar() {
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={() => setVisibleTaskCount((prev) => prev + 5)}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-[var(--text-icon)] text-sm hover-hover:bg-[var(--surface-active)]'
onClick={handleSeeMoreTasks}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-[var(--text-icon)] text-sm hover-hover:bg-[var(--surface-hover)]'
>
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
<span className='font-base'>See more</span>
@@ -1429,7 +1450,7 @@ export const Sidebar = memo(function Sidebar() {
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-active)]'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
disabled={!canEdit}
>
{isImporting || isCreatingFolder ? (
@@ -1469,7 +1490,7 @@ export const Sidebar = memo(function Sidebar() {
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-active)]'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow || !canEdit}
>
@@ -1485,23 +1506,11 @@ export const Sidebar = memo(function Sidebar() {
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={
<div
className='h-[16px] w-[16px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: 'var(--text-icon)',
borderColor: 'color-mix(in srgb, var(--text-icon) 60%, transparent)',
backgroundClip: 'padding-box',
}}
/>
}
icon={workflowsCollapsedIcon}
hover={workflowsHover}
ariaLabel='Workflows'
className='mt-1.5'
primaryAction={{
label: 'New workflow',
onSelect: handleCreateWorkflow,
}}
primaryAction={workflowsPrimaryAction}
>
{workflowsLoading && regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>
@@ -1523,10 +1532,10 @@ export const Sidebar = memo(function Sidebar() {
isRenamingWorkflow={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={() => void workflowFlyoutRename.saveRename()}
onWorkflowContextMenu={handleCollapsedWorkflowContextMenu}
onWorkflowMorePointerDown={handleCollapsedWorkflowMorePointerDown}
onWorkflowMoreClick={handleCollapsedWorkflowMoreClick}
onEditBlur={handleWorkflowRenameBlur}
onWorkflowOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
onWorkflowRename={handleCollapsedWorkflowRename}
canRenameWorkflow={canEdit}
/>
{(workflowsByFolder.root || []).map((workflow) => (
<CollapsedWorkflowFlyoutItem
@@ -1540,10 +1549,10 @@ export const Sidebar = memo(function Sidebar() {
isRenaming={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={() => void workflowFlyoutRename.saveRename()}
onContextMenu={handleCollapsedWorkflowContextMenu}
onMorePointerDown={handleCollapsedWorkflowMorePointerDown}
onMoreClick={handleCollapsedWorkflowMoreClick}
onEditBlur={handleWorkflowRenameBlur}
onOpenInNewTab={() => handleCollapsedWorkflowOpenInNewTab(workflow)}
onRename={() => handleCollapsedWorkflowRename(workflow)}
canRename={canEdit}
/>
))}
</>
@@ -1585,7 +1594,7 @@ export const Sidebar = memo(function Sidebar() {
<button
type='button'
data-item-id='help'
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px] hover:bg-[var(--surface-active)]'
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px] hover-hover:bg-[var(--surface-hover)]'
>
<HelpCircle className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='sidebar-collapse-hide truncate font-base text-[var(--text-body)]'>
@@ -1601,15 +1610,11 @@ export const Sidebar = memo(function Sidebar() {
)}
</Tooltip.Root>
<DropdownMenuContent align='start' side='top' sideOffset={4}>
<DropdownMenuItem
onSelect={() =>
window.open('https://docs.sim.ai', '_blank', 'noopener,noreferrer')
}
>
<DropdownMenuItem onSelect={handleOpenDocs}>
<BookOpen className='h-[14px] w-[14px]' />
Docs
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setIsHelpModalOpen(true)}>
<DropdownMenuItem onSelect={handleOpenHelpFromMenu}>
<HelpCircle className='h-[14px] w-[14px]' />
Report an issue
</DropdownMenuItem>
@@ -1622,7 +1627,7 @@ export const Sidebar = memo(function Sidebar() {
{footerItems.map((item) => (
<SidebarNavItem
key={`${item.id}-${isCollapsed}`}
key={item.id}
item={item}
active={false}
showCollapsedTooltips={showCollapsedTooltips}
@@ -1666,26 +1671,10 @@ export const Sidebar = memo(function Sidebar() {
disableDelete={!canEdit}
/>
<ContextMenu
isOpen={isCollapsedWorkflowContextMenuOpen}
position={collapsedWorkflowContextMenuPosition}
menuRef={collapsedWorkflowMenuRef}
onClose={closeCollapsedWorkflowContextMenu}
onOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
onRename={handleStartCollapsedWorkflowRename}
onDelete={() => {}}
showOpenInNewTab={true}
showRename={true}
showDuplicate={false}
showColorChange={false}
showDelete={false}
disableRename={!canEdit}
/>
{/* Task Delete Confirmation Modal */}
<DeleteModal
isOpen={isTaskDeleteModalOpen}
onClose={() => setIsTaskDeleteModalOpen(false)}
onClose={handleCloseTaskDeleteModal}
onConfirm={handleConfirmDeleteTasks}
isDeleting={deleteTaskMutation.isPending || deleteTasksMutation.isPending}
itemType='task'
@@ -1732,7 +1721,7 @@ export const Sidebar = memo(function Sidebar() {
ref={workspaceFileInputRef}
type='file'
accept='.zip'
style={{ display: 'none' }}
style={hiddenStyle}
onChange={handleWorkspaceFileChange}
/>
</>

View File

@@ -21,7 +21,7 @@ import { Input } from '../input/input'
import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '../popover/popover'
const comboboxVariants = cva(
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus-visible:border-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50 hover-hover:bg-[var(--surface-7)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]',
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
@@ -520,7 +520,7 @@ const Combobox = memo(
<Input
ref={inputRef}
className={cn(
'w-full pr-10 font-medium transition-colors hover-hover:bg-[var(--surface-7)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]',
'w-full pr-10 font-medium transition-colors',
(overlayContent || SelectedIcon) && 'text-transparent caret-foreground',
SelectedIcon && !overlayContent && 'pl-7',
open && 'focus-visible:border-[var(--border-1)]',
@@ -747,8 +747,8 @@ const Combobox = memo(
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-1.5 font-medium font-sans',
size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm',
'hover-hover:bg-[var(--border-1)]',
(isHighlighted || isSelected) && 'bg-[var(--border-1)]',
'hover-hover:bg-[var(--surface-active)]',
(isHighlighted || isSelected) && 'bg-[var(--surface-active)]',
option.disabled && 'cursor-not-allowed opacity-50'
)}
>
@@ -787,8 +787,8 @@ const Combobox = memo(
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-1.5 font-medium font-sans',
size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm',
'hover-hover:bg-[var(--border-1)]',
!multiSelectValues?.length && 'bg-[var(--border-1)]'
'hover-hover:bg-[var(--surface-active)]',
!multiSelectValues?.length && 'bg-[var(--surface-active)]'
)}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
@@ -821,8 +821,8 @@ const Combobox = memo(
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-1.5 font-medium font-sans',
size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm',
'hover-hover:bg-[var(--border-1)]',
(isHighlighted || isSelected) && 'bg-[var(--border-1)]',
'hover-hover:bg-[var(--surface-active)]',
(isHighlighted || isSelected) && 'bg-[var(--surface-active)]',
option.disabled && 'cursor-not-allowed opacity-50'
)}
>

View File

@@ -40,7 +40,7 @@ import { cn } from '@/lib/core/utils/cn'
* Matches the combobox and input styling patterns.
*/
const datePickerVariants = cva(
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus-visible:border-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50 hover-hover:border-[var(--surface-7)] hover-hover:bg-[var(--surface-5)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]',
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {

View File

@@ -26,7 +26,7 @@ import { cn } from '@/lib/core/utils/cn'
* Currently supports a 'default' variant.
*/
const inputVariants = cva(
'flex w-full touch-manipulation rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 font-medium font-sans text-sm text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none focus-visible:border-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50',
'flex w-full touch-manipulation rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 font-medium font-sans text-sm text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {

View File

@@ -116,8 +116,8 @@ const STYLES = {
/** Interactive state styles: default, secondary (brand), inverted (dark bg in light mode) */
states: {
default: {
active: 'bg-[var(--border-1)]',
hover: 'hover-hover:bg-[var(--border-1)]',
active: 'bg-[var(--surface-active)]',
hover: 'hover-hover:bg-[var(--surface-active)]',
},
secondary: {
active: 'bg-[var(--brand-secondary)] text-white [&_svg]:text-white',

View File

@@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/core/utils/cn'
const textareaVariants = cva(
'flex w-full touch-manipulation rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-2 font-medium font-sans text-sm text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none focus-visible:border-[var(--text-muted)] resize-none overflow-auto disabled:cursor-not-allowed disabled:opacity-50',
'flex w-full touch-manipulation rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-2 font-medium font-sans text-sm text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none resize-none overflow-auto disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {

View File

@@ -40,7 +40,7 @@ import { cn } from '@/lib/core/utils/cn'
* Matches the input and combobox styling patterns.
*/
const timePickerVariants = cva(
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus-visible:border-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50 hover-hover:border-[var(--surface-7)] hover-hover:bg-[var(--surface-5)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)] transition-colors',
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors',
{
variants: {
variant: {
@@ -256,7 +256,7 @@ const TimePicker = React.forwardRef<HTMLDivElement, TimePickerProps>(
<div className='flex items-center gap-1.5'>
<input
ref={hourInputRef}
className='w-[40px] rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-1.5 py-[5px] text-center font-medium font-sans text-[var(--text-primary)] text-small outline-none transition-colors placeholder:text-[var(--text-muted)] focus-visible:border-[var(--text-muted)]'
className='w-[40px] rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-1.5 py-[5px] text-center font-medium font-sans text-[var(--text-primary)] text-small outline-none transition-colors placeholder:text-[var(--text-muted)]'
value={hour}
onChange={handleHourChange}
onBlur={handleHourBlur}
@@ -268,7 +268,7 @@ const TimePicker = React.forwardRef<HTMLDivElement, TimePickerProps>(
/>
<span className='font-medium text-[var(--text-muted)] text-small'>:</span>
<input
className='w-[40px] rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-1.5 py-[5px] text-center font-medium font-sans text-[var(--text-primary)] text-small outline-none transition-colors placeholder:text-[var(--text-muted)] focus-visible:border-[var(--text-muted)]'
className='w-[40px] rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-1.5 py-[5px] text-center font-medium font-sans text-[var(--text-primary)] text-small outline-none transition-colors placeholder:text-[var(--text-muted)]'
value={minute}
onChange={handleMinuteChange}
onBlur={handleMinuteBlur}
@@ -291,7 +291,7 @@ const TimePicker = React.forwardRef<HTMLDivElement, TimePickerProps>(
'px-2 py-[5px] font-medium font-sans text-caption transition-colors',
ampm === period
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
: 'bg-[var(--surface-5)] text-[var(--text-secondary)] hover-hover:bg-[var(--surface-7)] hover-hover:text-[var(--text-primary)] dark:hover-hover:bg-[var(--surface-5)]'
: 'bg-[var(--surface-5)] text-[var(--text-secondary)] hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)]'
)}
>
{period}

View File

@@ -1287,7 +1287,9 @@ export function AccessControl() {
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{deletingGroup?.name}</span>?
All members will be removed from this group.{' '}
<span className='text-[var(--text-error)]'>
All members will be removed from this group.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>