mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
9 Commits
v0.6.13
...
feat/paste
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14089f7dbb | ||
|
|
b90bb75cda | ||
|
|
fb233d003d | ||
|
|
34df3333d1 | ||
|
|
23677d41a0 | ||
|
|
a489f91085 | ||
|
|
ed6e7845cc | ||
|
|
e698f9fe14 | ||
|
|
db1798267e |
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)]'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)]' />
|
||||
|
||||
@@ -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)]' />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user