feat(ui): logs, kb, emcn (#2207)

* feat(kb): emcn alignment; sidebar: popover primary; settings-modal: expand

* feat: EMCN breadcrumb; improvement(KB): UI

* fix: hydration error

* improvement(KB): UI

* feat: emcn modal sizing, KB tags; refactor: deleted old sidebar

* feat(logs): UI

* fix: add documents modal name

* feat: logs, emcn, cursorrules; refactor: logs

* feat: dashboard

* feat: notifications; improvement: logs details

* fixed random rectangle on canvas

* fixed the name of the file to align

* fix build

---------

Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
Emir Karabeg
2025-12-09 20:50:28 -08:00
committed by GitHub
parent 3cec449402
commit 0083c89fa5
266 changed files with 12111 additions and 19346 deletions

View File

@@ -0,0 +1,45 @@
---
description: EMCN component library patterns with CVA
globs: ["apps/sim/components/emcn/**"]
---
# EMCN Component Guidelines
## When to Use CVA vs Direct Styles
**Use CVA (class-variance-authority) when:**
- 2+ visual variants (primary, secondary, outline)
- Multiple sizes or state variations
- Example: Button with variants
**Use direct className when:**
- Single consistent style
- No variations needed
- Example: Label with one style
## Patterns
**With CVA:**
```tsx
const buttonVariants = cva('base-classes', {
variants: {
variant: { default: '...', primary: '...' },
size: { sm: '...', md: '...' }
}
})
export { Button, buttonVariants }
```
**Without CVA:**
```tsx
function Label({ className, ...props }) {
return <Primitive className={cn('single-style-classes', className)} {...props} />
}
```
## Rules
- Use Radix UI primitives for accessibility
- Export component and variants (if using CVA)
- TSDoc with usage examples
- Consistent tokens: `font-medium`, `text-[12px]`, `rounded-[4px]`
- Always use `transition-colors` for hover states

20
.cursor/rules/global.mdc Normal file
View File

@@ -0,0 +1,20 @@
---
description: Global coding standards that apply to all files
alwaysApply: true
---
# Global Standards
You are a professional software engineer. All code must follow best practices: accurate, readable, clean, and efficient.
## Logging
Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
## Comments
Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
## Styling
Never update global styles. Keep all styling local to components.
## Package Manager
Use `bun` and `bunx`, not `npm` and `npx`.

View File

@@ -0,0 +1,67 @@
---
description: Core architecture principles for the Sim app
globs: ["apps/sim/**"]
---
# Sim App Architecture
## Core Principles
1. **Single Responsibility**: Each component, hook, store has one clear purpose
2. **Composition Over Complexity**: Break down complex logic into smaller pieces
3. **Type Safety First**: TypeScript interfaces for all props, state, return types
4. **Predictable State**: Zustand for global state, useState for UI-only concerns
5. **Performance by Default**: useMemo, useCallback, refs appropriately
## File Organization
```
feature/
├── components/ # Feature components
│ └── sub-feature/ # Sub-feature with own components
├── hooks/ # Custom hooks
└── feature.tsx # Main component
```
## Naming Conventions
- **Components**: PascalCase (`WorkflowList`, `TriggerPanel`)
- **Hooks**: camelCase with `use` prefix (`useWorkflowOperations`)
- **Files**: kebab-case matching export (`workflow-list.tsx`)
- **Stores**: kebab-case in stores/ (`sidebar/store.ts`)
- **Constants**: SCREAMING_SNAKE_CASE
- **Interfaces**: PascalCase with suffix (`WorkflowListProps`)
## State Management
**useState**: UI-only concerns (dropdown open, hover, form inputs)
**Zustand**: Shared state, persistence, global app state
**useRef**: DOM refs, avoiding dependency issues, mutable non-reactive values
## Component Extraction
**Extract to separate file when:**
- Complex (50+ lines)
- Used across 2+ files
- Has own state/logic
**Keep inline when:**
- Simple (< 10 lines)
- Used in only 1 file
- Purely presentational
**Never import utilities from another component file.** Extract shared helpers to `lib/` or `utils/`.
## Utils Files
**Never create a `utils.ts` file for a single consumer.** Inline the logic directly in the consuming component.
**Create `utils.ts` when:**
- 2+ files import the same helper
**Prefer existing sources of truth:**
- Before duplicating logic, check if a centralized helper already exists (e.g., `lib/logs/get-trigger-options.ts`)
- Import from the source of truth rather than creating wrapper functions
**Location hierarchy:**
- `lib/` — App-wide utilities (auth, billing, core)
- `feature/utils.ts` — Feature-scoped utilities (used by 2+ components in the feature)
- Inline — Single-use helpers (define directly in the component)

View File

@@ -0,0 +1,64 @@
---
description: Component patterns and structure for React components
globs: ["apps/sim/**/*.tsx"]
---
# Component Patterns
## Structure Order
```typescript
'use client' // Only if using hooks
// 1. Imports (external → internal → relative)
// 2. Constants at module level
const CONFIG = { SPACING: 8 } as const
// 3. Props interface with TSDoc
interface ComponentProps {
/** Description */
requiredProp: string
optionalProp?: boolean
}
// 4. Component with TSDoc
export function Component({ requiredProp, optionalProp = false }: ComponentProps) {
// a. Refs
// b. External hooks (useParams, useRouter)
// c. Store hooks
// d. Custom hooks
// e. Local state
// f. useMemo computations
// g. useCallback handlers
// h. useEffect
// i. Return JSX
}
```
## Rules
1. Add `'use client'` when using React hooks
2. Always define props interface
3. TSDoc on component: description, @param, @returns
4. Extract constants with `as const`
5. Use Tailwind only, no inline styles
6. Semantic HTML (`aside`, `nav`, `article`)
7. Include ARIA attributes where appropriate
8. Optional chain callbacks: `onAction?.(id)`
## Factory Pattern with Caching
When generating components for a specific signature (e.g., icons):
```typescript
const cache = new Map<string, React.ComponentType<{ className?: string }>>()
function getColorIcon(color: string) {
if (cache.has(color)) return cache.get(color)!
const Icon = ({ className }: { className?: string }) => (
<div className={cn(className, 'rounded-[3px]')} style={{ backgroundColor: color, width: 10, height: 10 }} />
)
Icon.displayName = `ColorIcon(${color})`
cache.set(color, Icon)
return Icon
}
```

View File

@@ -0,0 +1,68 @@
---
description: Custom hook patterns and best practices
globs: ["apps/sim/**/use-*.ts", "apps/sim/**/hooks/**/*.ts"]
---
# Hook Patterns
## Structure
```typescript
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('useFeatureName')
interface UseFeatureProps {
id: string
onSuccess?: (result: Result) => void
}
/**
* Hook description.
* @param props - Configuration
* @returns State and operations
*/
export function useFeature({ id, onSuccess }: UseFeatureProps) {
// 1. Refs for stable dependencies
const idRef = useRef(id)
const onSuccessRef = useRef(onSuccess)
// 2. State
const [data, setData] = useState<Data | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
// 3. Sync refs
useEffect(() => {
idRef.current = id
onSuccessRef.current = onSuccess
}, [id, onSuccess])
// 4. Operations with useCallback
const fetchData = useCallback(async () => {
setIsLoading(true)
try {
const result = await fetch(`/api/${idRef.current}`).then(r => r.json())
setData(result)
onSuccessRef.current?.(result)
} catch (err) {
setError(err as Error)
logger.error('Failed', { error: err })
} finally {
setIsLoading(false)
}
}, []) // Empty deps - using refs
// 5. Return grouped by state/operations
return { data, isLoading, error, fetchData }
}
```
## Rules
1. Single responsibility per hook
2. Props interface required
3. TSDoc required
4. Use logger, not console.log
5. Refs for stable callback dependencies
6. Wrap returned functions in useCallback
7. Always try/catch async operations
8. Track loading/error states

View File

@@ -0,0 +1,37 @@
---
description: Import patterns for the Sim application
globs: ["apps/sim/**/*.ts", "apps/sim/**/*.tsx"]
---
# Import Patterns
## EMCN Components
Import from `@/components/emcn`, never from subpaths like `@/components/emcn/components/modal/modal`.
**Exception**: CSS imports use actual file paths: `import '@/components/emcn/components/code/code.css'`
## Feature Components
Import from central folder indexes, not specific subfolders:
```typescript
// ✅ Correct
import { Dashboard, Sidebar } from '@/app/workspace/[workspaceId]/logs/components'
// ❌ Wrong
import { Dashboard } from '@/app/workspace/[workspaceId]/logs/components/dashboard'
```
## Internal vs External
- **Cross-feature**: Absolute paths through central index
- **Within feature**: Relative paths (`./components/...`, `../utils`)
## Import Order
1. React/core libraries
2. External libraries
3. UI components (`@/components/emcn`, `@/components/ui`)
4. Utilities (`@/lib/...`)
5. Feature imports from indexes
6. Relative imports
7. CSS imports
## Types
Use `type` keyword: `import type { WorkflowLog } from '...'`

View File

@@ -0,0 +1,57 @@
---
description: Zustand store patterns
globs: ["apps/sim/**/store.ts", "apps/sim/**/stores/**/*.ts"]
---
# Zustand Store Patterns
## Structure
```typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FeatureState {
// State
items: Item[]
activeId: string | null
// Actions
setItems: (items: Item[]) => void
addItem: (item: Item) => void
clearState: () => void
}
const createInitialState = () => ({
items: [],
activeId: null,
})
export const useFeatureStore = create<FeatureState>()(
persist(
(set) => ({
...createInitialState(),
setItems: (items) => set({ items }),
addItem: (item) => set((state) => ({
items: [...state.items, item],
})),
clearState: () => set(createInitialState()),
}),
{
name: 'feature-state',
partialize: (state) => ({ items: state.items }),
}
)
)
```
## Rules
1. Interface includes state and actions
2. Extract config to module constants
3. TSDoc on store
4. Only persist what's needed
5. Immutable updates only - never mutate
6. Use `set((state) => ...)` when depending on previous state
7. Provide clear/reset actions

View File

@@ -0,0 +1,47 @@
---
description: Tailwind CSS and styling conventions
globs: ["apps/sim/**/*.tsx", "apps/sim/**/*.css"]
---
# Styling Rules
## Tailwind
1. **No inline styles** - Use Tailwind classes exclusively
2. **No duplicate dark classes** - Don't add `dark:` when value matches light mode
3. **Exact values** - Use design system values (`text-[14px]`, `h-[25px]`)
4. **Prefer px** - Use `px-[4px]` over `px-1`
5. **Transitions** - Add `transition-colors` for interactive states
## Conditional Classes
```typescript
import { cn } from '@/lib/utils'
<div className={cn(
'base-classes',
isActive && 'active-classes',
disabled ? 'opacity-60' : 'hover:bg-accent'
)} />
```
## CSS Variables for Dynamic Styles
```typescript
// In store setter
setSidebarWidth: (width) => {
set({ sidebarWidth: width })
document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
}
// In component
<aside style={{ width: 'var(--sidebar-width)' }} />
```
## Anti-Patterns
```typescript
// ❌ Bad
<div style={{ width: 200 }}>
<div className='text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
// ✅ Good
<div className='w-[200px]'>
<div className='text-[var(--text-primary)]'>
```

View File

@@ -0,0 +1,24 @@
---
description: TypeScript conventions and type safety
globs: ["apps/sim/**/*.ts", "apps/sim/**/*.tsx"]
---
# TypeScript Rules
1. **No `any`** - Use proper types or `unknown` with type guards
2. **Props interface** - Always define, even for simple components
3. **Callback types** - Full signature with params and return type
4. **Generics** - Use for reusable components/hooks
5. **Const assertions** - `as const` for constant objects/arrays
6. **Ref types** - Explicit: `useRef<HTMLDivElement>(null)`
## Anti-Patterns
```typescript
// ❌ Bad
const handleClick = (e: any) => {}
useEffect(() => { doSomething(prop) }, []) // Missing dep
// ✅ Good
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {}
useEffect(() => { doSomething(prop) }, [prop])
```

View File

@@ -1,19 +0,0 @@
# Role
You are a professional software engineer. All code you write MUST follow best practices, ensuring accuracy, quality, readability, and cleanliness. You MUST make FOCUSED EDITS that are EFFICIENT and ELEGANT.
## Logs
ENSURE that you use the logger.info and logger.warn and logger.error instead of the console.log whenever you want to display logs.
## Comments
You must use TSDOC for comments. Do not use ==== for comments to separate sections. Do not leave any comments that are not TSDOC.
## Globals styles
You should not update the global styles unless it is absolutely necessary. Keep all styling local to components and files.
## Bun
Use bun and bunx not npm and npx

47
CLAUDE.md Normal file
View File

@@ -0,0 +1,47 @@
# Expert Programming Standards
**You are tasked with implementing solutions that follow best practices. You MUST be accurate, elegant, and efficient as an expert programmer.**
---
# Role
You are a professional software engineer. All code you write MUST follow best practices, ensuring accuracy, quality, readability, and cleanliness. You MUST make FOCUSED EDITS that are EFFICIENT and ELEGANT.
## Logs
ENSURE that you use the logger.info and logger.warn and logger.error instead of the console.log whenever you want to display logs.
## Comments
You must use TSDOC for comments. Do not use ==== for comments to separate sections. Do not leave any comments that are not TSDOC.
## Global Styles
You should not update the global styles unless it is absolutely necessary. Keep all styling local to components and files.
## Bun
Use bun and bunx not npm and npx.
## Code Quality
- Write clean, maintainable code that follows the project's existing patterns
- Prefer composition over inheritance
- Keep functions small and focused on a single responsibility
- Use meaningful variable and function names
- Handle errors gracefully and provide useful error messages
- Write type-safe code with proper TypeScript types
## Testing
- Write tests for new functionality when appropriate
- Ensure existing tests pass before completing work
- Follow the project's testing conventions
## Performance
- Consider performance implications of your code
- Avoid unnecessary re-renders in React components
- Use appropriate data structures and algorithms
- Profile and optimize when necessary

View File

@@ -1,777 +0,0 @@
# Sim App Architecture Guidelines
You are building features in the Sim app following the architecture. This file defines the patterns, structures, and conventions you must follow.
---
## Core Principles
1. **Single Responsibility Principle**: Each component, hook, and store should have one clear purpose
2. **Composition Over Complexity**: Break down complex logic into smaller, composable pieces
3. **Type Safety First**: Use TypeScript interfaces for all props, state, and return types
4. **Predictable State Management**: Use Zustand for global state, local state for UI-only concerns
5. **Performance by Default**: Use useMemo, useCallback, and refs appropriately
6. **Accessibility**: Include semantic HTML and ARIA attributes
7. **Documentation**: Use TSDoc for all public interfaces and keep it in sync with code changes
---
## File Organization
### Directory Structure
```
feature/
├── components/ # Feature components
│ ├── sub-feature/ # Sub-feature with its own components
│ │ ├── component.tsx
│ │ └── index.ts
├── hooks/ # Custom hooks for feature logic
│ ├── use-feature-logic.ts
│ └── use-another-hook.ts
└── feature.tsx # Main feature component
```
### Naming Conventions
- **Components**: PascalCase with descriptive names (`WorkflowList`, `TriggerPanel`)
- **Hooks**: camelCase with `use` prefix (`useWorkflowOperations`, `usePanelResize`)
- **Files**: kebab-case matching export name (`workflow-list.tsx`, `use-panel-resize.ts`)
- **Stores**: kebab-case in stores/ directory (`sidebar/store.ts`, `workflows/registry/store.ts`)
- **Constants**: SCREAMING_SNAKE_CASE at module level
- **Interfaces**: PascalCase with descriptive suffix (`WorkflowListProps`, `UseWorkspaceManagementProps`)
---
## Component Architecture
### Component Structure Template
```typescript
'use client' // Only if using hooks like useState, useEffect, etc.
import { useCallback, useMemo, useRef, useState } from 'react'
// Other imports organized: external, internal paths, relative
/**
* Constants - Define at module level before component
*/
const DEFAULT_VALUE = 100
const MIN_VALUE = 50
const MAX_VALUE = 200
const CONFIG = {
SPACING: 8,
ITEM_HEIGHT: 25,
} as const
interface ComponentProps {
/** Description of prop */
requiredProp: string
/** Description with default noted */
optionalProp?: boolean
onAction?: (id: string) => void
}
/**
* Component description explaining purpose and key features.
* Mention important integrations, hooks, or patterns used.
*
* @param props - Component props
* @returns JSX description
*/
export function ComponentName({
requiredProp,
optionalProp = false,
onAction,
}: ComponentProps) {
// 1. Refs first
const containerRef = useRef<HTMLDivElement>(null)
// 2. External hooks (router, params, context)
const params = useParams()
// 3. Store hooks
const { state, actions } = useStore()
// 4. Custom hooks (your feature hooks)
const { data, isLoading } = useCustomHook({ requiredProp })
// 5. Local state (UI-only concerns)
const [isOpen, setIsOpen] = useState(false)
// 6. Derived/computed values with useMemo
const filteredData = useMemo(() => {
return data.filter(item => item.active)
}, [data])
// 7. Callbacks with useCallback
const handleClick = useCallback((id: string) => {
onAction?.(id)
}, [onAction])
// 8. Effects
useEffect(() => {
// Setup logic
return () => {
// Cleanup logic
}
}, [])
// 9. Render helpers (if complex)
const renderItem = useCallback((item: Item) => (
<div key={item.id}>{item.name}</div>
), [])
// 10. Return JSX
return (
<div ref={containerRef} className='...' aria-label='...'>
{/* Section comments for clarity */}
{/* Header */}
<header>...</header>
{/* Content */}
<main>...</main>
</div>
)
}
```
### Component Rules
1. **Client Components**: Add `'use client'` directive when using React hooks
2. **Props Interface**: Always define TypeScript interface, even for simple props
3. **TSDoc Required and Up-to-Date**: Include description, @param, and @returns. Update TSDoc whenever props, behavior, or side effects change (including additions and deletions).
4. **Constants**: Extract magic numbers and config to module-level constants using `as const`
5. **No Inline Styles**: Use Tailwind classes exclusively (CSS variables for dynamic values)
6. **Section Comments**: Use comments to mark logical sections of JSX
7. **Semantic HTML**: Use appropriate elements (`aside`, `nav`, `article`, etc.)
8. **ARIA Attributes**: Include `aria-label`, `aria-orientation`, `role` where appropriate
9. **Refs for DOM**: Use refs for direct DOM access, not state
10. **Callback Props**: Always use optional chaining for callback props (`onAction?.(...)`)
---
## Custom Hooks Architecture
### Hook Structure Template
```typescript
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('useFeatureName')
/**
* Constants specific to this hook
*/
const DEFAULT_CONFIG = {
timeout: 1000,
retries: 3,
} as const
interface UseFeatureNameProps {
/** Description of required prop */
id: string
/** Optional callback fired on success */
onSuccess?: (result: Result) => void
}
/**
* Custom hook to [clear description of purpose].
* [Additional context about what it manages or coordinates].
*
* @param props - Configuration object containing id and callbacks
* @returns Feature state and operations
*/
export function useFeatureName({ id, onSuccess }: UseFeatureNameProps) {
// 1. Refs (to avoid dependency issues)
const idRef = useRef(id)
const onSuccessRef = useRef(onSuccess)
// 2. State
const [data, setData] = useState<Data | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
// 3. Update refs when values change
useEffect(() => {
idRef.current = id
onSuccessRef.current = onSuccess
}, [id, onSuccess])
// 4. Operations with useCallback (stable references)
const fetchData = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`/api/data/${idRef.current}`)
const result = await response.json()
setData(result)
onSuccessRef.current?.(result)
logger.info('Data fetched successfully', { id: idRef.current })
} catch (err) {
const error = err as Error
setError(error)
logger.error('Failed to fetch data', { error })
} finally {
setIsLoading(false)
}
}, []) // Empty deps because using refs
const updateData = useCallback(async (newData: Partial<Data>) => {
try {
logger.info('Updating data', { id: idRef.current, newData })
const response = await fetch(`/api/data/${idRef.current}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newData),
})
const result = await response.json()
setData(result)
return true
} catch (err) {
logger.error('Failed to update data', { error: err })
return false
}
}, [])
// 5. Effects
useEffect(() => {
if (id) {
fetchData()
}
}, [id, fetchData])
// 6. Return object - group by state and operations
return {
// State
data,
isLoading,
error,
// Operations
fetchData,
updateData,
}
}
```
### Hook Rules
1. **Single Responsibility**: Each hook manages one concern (data fetching, resize, navigation)
2. **Props Interface**: Define TypeScript interface for all parameters
3. **TSDoc Required and Up-to-Date**: Include clear description, @param, and @returns. Update TSDoc whenever inputs, outputs, behavior, or side effects change (including additions and deletions).
4. **Logger Usage**: Import and use logger instead of console.log
5. **Refs for Stable Deps**: Use refs to avoid recreating callbacks unnecessarily
6. **useCallback Always**: Wrap all returned functions in useCallback
7. **Grouped Returns**: Return object with comments separating State and Operations
8. **Error Handling**: Always try/catch async operations and log errors
9. **Loading States**: Track loading, error states for async operations
10. **Dependency Arrays**: Be explicit and correct with all dependency arrays
---
## Store Architecture (Zustand)
### Store Structure Template
```typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
/**
* Store state interface
*/
interface FeatureState {
// State properties
items: Item[]
activeId: string | null
isLoading: boolean
// Actions
setItems: (items: Item[]) => void
setActiveId: (id: string | null) => void
addItem: (item: Item) => void
removeItem: (id: string) => void
clearState: () => void
}
/**
* Constants - Configuration values
*/
const DEFAULT_CONFIG = {
maxItems: 100,
cacheTime: 3600,
} as const
/**
* Initial state factory
*/
const createInitialState = () => ({
items: [],
activeId: null,
isLoading: false,
})
/**
* Feature store managing [description].
* [Additional context about what this store coordinates].
*/
export const useFeatureStore = create<FeatureState>()(
persist(
(set, get) => ({
...createInitialState(),
setItems: (items) => set({ items }),
setActiveId: (id) => set({ activeId: id }),
addItem: (item) =>
set((state) => ({
items: [...state.items, item].slice(-DEFAULT_CONFIG.maxItems),
})),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((item) => item.id !== id),
activeId: state.activeId === id ? null : state.activeId,
})),
clearState: () => set(createInitialState()),
}),
{
name: 'feature-state',
// Optionally customize what to persist
partialize: (state) => ({
items: state.items,
activeId: state.activeId,
}),
onRehydrateStorage: () => (state) => {
// Validate and transform persisted state if needed
if (state) {
// Enforce constraints
if (state.items.length > DEFAULT_CONFIG.maxItems) {
state.items = state.items.slice(-DEFAULT_CONFIG.maxItems)
}
}
},
}
)
)
```
### Store Rules
1. **Interface First**: Define TypeScript interface including both state and actions
2. **Constants**: Extract configuration values to module-level constants
3. **TSDoc on Store**: Document what the store manages
4. **Persist Strategically**: Only persist what's needed across sessions
5. **Validation**: Use onRehydrateStorage to validate persisted state
6. **Immutable Updates**: Use set() with new objects/arrays, never mutate
7. **Derived State**: Use getters or selectors, not stored computed values
8. **CSS Variables**: Update CSS variables in setters for hydration-safe dynamic styles
9. **Cleanup Actions**: Provide clear/reset actions for state cleanup
10. **Functional Updates**: Use `set((state) => ...)` when new state depends on old state
---
## State Management Strategy
### When to Use Local State (useState)
- UI-only concerns (dropdown open, hover states, form inputs)
- Component-scoped state not needed elsewhere
- Temporary state that doesn't need persistence
### When to Use Zustand Store
- Shared state across multiple components
- State that needs persistence (localStorage)
- Global application state (user, theme, settings)
- Complex state with many actions/reducers
### When to Use Refs (useRef)
- DOM element references
- Avoiding dependency issues in hooks
- Storing mutable values that don't trigger re-renders
- Accessing latest props/state in callbacks without recreating them
---
## CSS and Styling
### CSS Variables for Dynamic Styles
Use CSS variables for values that need to persist across hydration:
```typescript
// In store setter
setSidebarWidth: (width) => {
const clampedWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width))
set({ sidebarWidth: clampedWidth })
// Update CSS variable for immediate visual feedback
if (typeof window !== 'undefined') {
document.documentElement.style.setProperty('--sidebar-width', `${clampedWidth}px`)
}
}
// In component
<aside
className='sidebar-container'
style={{ width: 'var(--sidebar-width)' }}
>
```
### Tailwind Classes
1. **No Inline Styles**: Use Tailwind utility classes exclusively
2. **Dark Mode**: Include dark mode variants only when the value differs from light mode
3. **No Duplicate Dark Classes**: Never add a `dark:` class when the value is identical to the light mode class (e.g., `text-[var(--text-primary)] dark:text-[var(--text-primary)]` is redundant - just use `text-[var(--text-primary)]`)
4. **Exact Values**: Use exact values from design system (`text-[14px]`, `h-[25px]`)
5. **cn for Conditionals**: Use `cn()` from `@/lib/utils` for conditional classes (wraps clsx + tailwind-merge for conflict resolution)
6. **Consistent Spacing**: Use spacing tokens (`gap-[8px]`, `px-[14px]`)
7. **Transitions**: Add transitions for interactive states (`transition-colors`)
8. **Prefer px units**: Use arbitrary px values over scale utilities (e.g., `px-[4px]` instead of `px-1`)
```typescript
import { cn } from '@/lib/utils'
<div
className={cn(
'base-classes that-always-apply',
isActive && 'active-state-classes',
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-accent'
)}
>
```
---
## TypeScript Patterns
### Interface Conventions
```typescript
// Component props
interface ComponentNameProps {
requiredProp: string
optionalProp?: boolean
}
// Hook props
interface UseHookNameProps {
id: string
onSuccess?: () => void
}
// Store state
interface FeatureState {
data: Data[]
isLoading: boolean
actions: () => void
}
// Return types (if complex)
interface UseHookNameReturn {
state: State
actions: Actions
}
```
### Type Safety Rules
1. **No `any`**: Use proper types or `unknown` with type guards
2. **Props Interface**: Always define, even for simple components
3. **Callback Types**: Define full signature including parameters and return type
4. **Generic Types**: Use generics for reusable components/hooks
5. **Const Assertions**: Use `as const` for constant objects/arrays
6. **Type Guards**: Create type guards for runtime checks
7. **Ref Types**: Explicitly type refs (`useRef<HTMLDivElement>(null)`)
---
## Performance Patterns
### Memoization
```typescript
// useMemo for expensive computations
const sortedItems = useMemo(() => {
return items.sort((a, b) => a.name.localeCompare(b.name))
}, [items])
// useCallback for functions passed as props
const handleClick = useCallback((id: string) => {
onItemClick?.(id)
}, [onItemClick])
// useCallback for render functions
const renderItem = useCallback((item: Item) => (
<ItemComponent key={item.id} item={item} onClick={handleClick} />
), [handleClick])
```
### When to Memoize
1. **useMemo**: Expensive calculations, filtering/sorting large arrays, object creation in render
2. **useCallback**: Functions passed to child components, dependencies in other hooks, event handlers used in effects
3. **Don't Over-Memoize**: Simple calculations, primitives, or functions not passed down
### Refs for Avoiding Recreations
```typescript
// Pattern: Use refs to avoid function recreations
const onSuccessRef = useRef(onSuccess)
useEffect(() => {
onSuccessRef.current = onSuccess
}, [onSuccess])
const stableCallback = useCallback(() => {
// Use ref so this callback never needs to change
onSuccessRef.current?.()
}, []) // Empty deps!
```
---
## Logging and Debugging
### Logger Usage
```typescript
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('ComponentName')
// Use throughout component/hook
logger.info('User action', { userId, action })
logger.warn('Potential issue', { details })
logger.error('Operation failed', { error })
```
### Logging Rules
1. **No console.log**: Use logger.info/warn/error instead
2. **Logger Per File**: Create logger with component/hook name
3. **Structured Logging**: Pass objects with context, not just strings
4. **Log Levels**:
- `info`: Normal operations, user actions, state changes
- `warn`: Unusual but handled situations, deprecations
- `error`: Failures, exceptions, errors that need attention
---
## Linting and Formatting
### Automated Linting
**Do not manually fix linting errors.** The project uses automated linting tools that should handle formatting and style issues.
### Rules
1. **No Manual Fixes**: Do not attempt to manually reorder CSS classes, fix formatting, or address linter warnings
2. **Use Automated Tools**: If linting errors need to be fixed, run `bun run lint` to let the automated tools handle it
3. **Focus on Logic**: Concentrate on functionality, TypeScript correctness, and architectural patterns
4. **Let Tools Handle Style**: Biome and other linters will automatically format code according to project standards
### When Linting Matters
- **Syntax Errors**: Fix actual syntax errors that prevent compilation
- **Type Errors**: Address TypeScript type errors that indicate logic issues
- **Ignore Style Warnings**: CSS class order, formatting preferences, etc. will be handled by tooling
```bash
# If linting is required
bun run lint
```
---
## Code Quality Checklist
Before considering a component/hook complete, verify:
### Documentation
- [ ] TSDoc in sync with implementation after any change (params/returns/behavior/throws)
- [ ] TSDoc comment on component/hook/store
- [ ] Props interface documented with /** */ comments
- [ ] Complex logic explained with inline comments
- [ ] Section comments in JSX for clarity
### TypeScript
- [ ] All props have interface defined
- [ ] No `any` types used
- [ ] Refs properly typed
- [ ] Return types explicit for complex hooks
### Performance
- [ ] useMemo for expensive computations
- [ ] useCallback for functions passed as props
- [ ] Refs used to avoid unnecessary recreations
- [ ] No unnecessary re-renders
### Hooks
- [ ] Correct dependency arrays
- [ ] Cleanup in useEffect return functions
- [ ] Stable callback references with useCallback
- [ ] Logic extracted to custom hooks when reusable
### Styling
- [ ] No styles attributes (use className with Tailwind)
- [ ] Dark mode variants only when values differ from light mode
- [ ] No duplicate dark: classes with identical values
- [ ] Consistent spacing using design tokens
- [ ] cn() for conditional classes
### Accessibility
- [ ] Semantic HTML elements
- [ ] ARIA labels and roles where needed
- [ ] Keyboard navigation support
- [ ] Focus management
### State Management
- [ ] Local state for UI-only concerns
- [ ] Zustand for shared/persisted state
- [ ] No duplicate state
- [ ] Clear state update patterns
### Error Handling
- [ ] try/catch around async operations
- [ ] Error states tracked and displayed
- [ ] Loading states for async actions
- [ ] Failures logged with context
---
## Anti-Patterns to Avoid
### ❌ Don't Do This
```typescript
// ❌ Inline styles
<div style={{ width: 200, marginTop: 10 }}>
// ❌ Duplicate dark mode classes (same value as light mode)
<div className='text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<div className='bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'>
<div className='hover:bg-[var(--border)] dark:hover:bg-[var(--border)]'>
// ❌ console.log
console.log('Debug info')
// ❌ any type
const handleClick = (e: any) => {}
// ❌ Missing dependencies
useEffect(() => {
doSomething(prop)
}, []) // Missing prop!
// ❌ Mutating state
const handleAdd = () => {
items.push(newItem) // Mutating!
setItems(items)
}
// ❌ No error handling
const fetchData = async () => {
const data = await fetch('/api/data')
setData(data)
}
// ❌ Complex logic in component
export function Component() {
const [data, setData] = useState([])
useEffect(() => {
// 50 lines of complex logic
}, [])
}
```
### ✅ Do This Instead
```typescript
// ✅ Tailwind classes
<div className='w-[200px] mt-[10px]'>
// ✅ No duplicate dark classes - CSS variables already handle theming
<div className='text-[var(--text-primary)]'>
<div className='bg-[var(--surface-9)]'>
<div className='hover:bg-[var(--border)]'>
// ✅ Only add dark: when values differ between modes
<div className='bg-[var(--surface-6)] dark:bg-[var(--surface-9)]'>
// ✅ Logger
logger.info('Debug info', { context })
// ✅ Proper types
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {}
// ✅ Complete dependencies
useEffect(() => {
doSomething(prop)
}, [prop])
// ✅ Immutable updates
const handleAdd = () => {
setItems([...items, newItem])
}
// ✅ Error handling
const fetchData = async () => {
try {
const response = await fetch('/api/data')
if (!response.ok) throw new Error('Failed to fetch')
const data = await response.json()
setData(data)
} catch (error) {
logger.error('Fetch failed', { error })
setError(error)
}
}
// ✅ Extract to custom hook
export function Component() {
const { data, isLoading, error } = useFeatureData()
}
```
---
## Examples from Codebase
Study these files as reference implementations:
### Components
- `sidebar-new.tsx` - Main component structure, hook composition
- `workflow-list.tsx` - Complex component with drag-drop, memoization
- `blocks.tsx` - Simple panel component with resize
- `triggers.tsx` - Similar panel pattern
### Hooks
- `use-workspace-management.ts` - Complex hook with multiple operations, refs pattern
- `use-sidebar-resize.ts` - Simple focused hook with event listeners
- `use-workflow-operations.ts` - Hook coordinating store and navigation
- `use-panel-resize.ts` - Shared resize logic pattern
### Stores
- `stores/sidebar/store.ts` - Persist middleware, CSS variables, validation
---
## Summary
This architecture prioritizes:
1. **Separation of Concerns**: Components render, hooks contain logic, stores manage state
2. **Type Safety**: TypeScript everywhere with no escape hatches
3. **Performance**: Smart use of memoization and refs
4. **Maintainability**: Clear structure, documentation, and consistent patterns
5. **Developer Experience**: Logging, error handling, and clear interfaces
When in doubt, follow the patterns established in the sidebar-new component family.

View File

@@ -19,7 +19,7 @@ import {
ENTERPRISE_PLAN_FEATURES,
PRO_PLAN_FEATURES,
TEAM_PLAN_FEATURES,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/plan-configs'
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs'
const logger = createLogger('LandingPricing')

View File

@@ -206,6 +206,9 @@
--terminal-status-info-bg: #f5f5f4; /* stone-100 */
--terminal-status-info-border: #a8a29e; /* stone-400 */
--terminal-status-info-color: #57534e; /* stone-600 */
--terminal-status-warning-bg: #fef9e7;
--terminal-status-warning-border: #f5c842;
--terminal-status-warning-color: #a16207;
}
.dark {
/* Neutrals (surfaces) */
@@ -336,6 +339,9 @@
--terminal-status-info-bg: #383838;
--terminal-status-info-border: #686868;
--terminal-status-info-color: #b7b7b7;
--terminal-status-warning-bg: #3d3520;
--terminal-status-warning-border: #5c4d1f;
--terminal-status-warning-color: #d4a72c;
}
}

View File

@@ -1,6 +1,6 @@
import { db } from '@sim/db'
import { pausedExecutions, permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm'
import { and, desc, eq, gte, inArray, isNotNull, isNull, lte, or, type SQL, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
@@ -126,13 +126,50 @@ export async function GET(request: NextRequest) {
// Build additional conditions for the query
let conditions: SQL | undefined
// Filter by level (supports comma-separated for OR conditions)
// Filter by level with support for derived statuses (running, pending)
if (params.level && params.level !== 'all') {
const levels = params.level.split(',').filter(Boolean)
if (levels.length === 1) {
conditions = and(conditions, eq(workflowExecutionLogs.level, levels[0]))
} else if (levels.length > 1) {
conditions = and(conditions, inArray(workflowExecutionLogs.level, levels))
const levelConditions: SQL[] = []
for (const level of levels) {
if (level === 'error') {
// Direct database field
levelConditions.push(eq(workflowExecutionLogs.level, 'error'))
} else if (level === 'info') {
// Completed info logs only (not running, not pending)
const condition = and(
eq(workflowExecutionLogs.level, 'info'),
isNotNull(workflowExecutionLogs.endedAt)
)
if (condition) levelConditions.push(condition)
} else if (level === 'running') {
// Running logs: info level with no endedAt
const condition = and(
eq(workflowExecutionLogs.level, 'info'),
isNull(workflowExecutionLogs.endedAt)
)
if (condition) levelConditions.push(condition)
} else if (level === 'pending') {
// Pending logs: info level with pause status indicators
const condition = and(
eq(workflowExecutionLogs.level, 'info'),
or(
sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`,
and(
isNotNull(pausedExecutions.status),
sql`${pausedExecutions.status} != 'fully_resumed'`
)
)
)
if (condition) levelConditions.push(condition)
}
}
if (levelConditions.length > 0) {
conditions = and(
conditions,
levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)
)
}
}

View File

@@ -1,6 +1,6 @@
import { db } from '@sim/db'
import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
import { and, eq, gte, inArray, lte } from 'drizzle-orm'
import { pausedExecutions, permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
import { and, eq, gte, inArray, isNotNull, isNull, lte, or, type SQL, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
@@ -15,6 +15,7 @@ const QueryParamsSchema = z.object({
workflowIds: z.string().optional(),
folderIds: z.string().optional(),
triggers: z.string().optional(),
level: z.string().optional(), // Supports comma-separated values: 'error,running'
})
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
@@ -84,20 +85,73 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
inArray(workflowExecutionLogs.workflowId, workflowIdList),
gte(workflowExecutionLogs.startedAt, start),
lte(workflowExecutionLogs.startedAt, end),
] as any[]
] as SQL[]
if (qp.triggers) {
const t = qp.triggers.split(',').filter(Boolean)
logWhere.push(inArray(workflowExecutionLogs.trigger, t))
}
// Handle level filtering with support for derived statuses and multiple selections
if (qp.level && qp.level !== 'all') {
const levels = qp.level.split(',').filter(Boolean)
const levelConditions: SQL[] = []
for (const level of levels) {
if (level === 'error') {
levelConditions.push(eq(workflowExecutionLogs.level, 'error'))
} else if (level === 'info') {
// Completed info logs only
const condition = and(
eq(workflowExecutionLogs.level, 'info'),
isNotNull(workflowExecutionLogs.endedAt)
)
if (condition) levelConditions.push(condition)
} else if (level === 'running') {
// Running logs: info level with no endedAt
const condition = and(
eq(workflowExecutionLogs.level, 'info'),
isNull(workflowExecutionLogs.endedAt)
)
if (condition) levelConditions.push(condition)
} else if (level === 'pending') {
// Pending logs: info level with pause status indicators
const condition = and(
eq(workflowExecutionLogs.level, 'info'),
or(
sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`,
and(
isNotNull(pausedExecutions.status),
sql`${pausedExecutions.status} != 'fully_resumed'`
)
)
)
if (condition) levelConditions.push(condition)
}
}
if (levelConditions.length > 0) {
const combinedCondition =
levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)
if (combinedCondition) logWhere.push(combinedCondition)
}
}
const logs = await db
.select({
workflowId: workflowExecutionLogs.workflowId,
level: workflowExecutionLogs.level,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
pausedTotalPauseCount: pausedExecutions.totalPauseCount,
pausedResumedCount: pausedExecutions.resumedCount,
pausedStatus: pausedExecutions.status,
})
.from(workflowExecutionLogs)
.leftJoin(
pausedExecutions,
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
)
.where(and(...logWhere))
type Bucket = {

View File

@@ -4,11 +4,41 @@ import { useEffect, useState } from 'react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { client, useSession } from '@/lib/auth/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { getErrorMessage } from '@/app/invite/[id]/utils'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
const logger = createLogger('InviteById')
function getErrorMessage(reason: string): string {
switch (reason) {
case 'missing-token':
return 'The invitation link is invalid or missing a required parameter.'
case 'invalid-token':
return 'The invitation link is invalid or has already been used.'
case 'expired':
return 'This invitation has expired. Please ask for a new invitation.'
case 'already-processed':
return 'This invitation has already been accepted or declined.'
case 'email-mismatch':
return 'This invitation was sent to a different email address. Please log in with the correct account.'
case 'workspace-not-found':
return 'The workspace associated with this invitation could not be found.'
case 'user-not-found':
return 'Your user account could not be found. Please try logging out and logging back in.'
case 'already-member':
return 'You are already a member of this organization or workspace.'
case 'already-in-organization':
return 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
case 'invalid-invitation':
return 'This invitation is invalid or no longer exists.'
case 'missing-invitation-id':
return 'The invitation link is missing required information. Please use the original invitation link.'
case 'server-error':
return 'An unexpected error occurred while processing your invitation. Please try again later.'
default:
return 'An unknown error occurred while processing your invitation.'
}
}
export default function Invite() {
const router = useRouter()
const params = useParams()

View File

@@ -1,30 +0,0 @@
export function getErrorMessage(reason: string): string {
switch (reason) {
case 'missing-token':
return 'The invitation link is invalid or missing a required parameter.'
case 'invalid-token':
return 'The invitation link is invalid or has already been used.'
case 'expired':
return 'This invitation has expired. Please ask for a new invitation.'
case 'already-processed':
return 'This invitation has already been accepted or declined.'
case 'email-mismatch':
return 'This invitation was sent to a different email address. Please log in with the correct account.'
case 'workspace-not-found':
return 'The workspace associated with this invitation could not be found.'
case 'user-not-found':
return 'Your user account could not be found. Please try logging out and logging back in.'
case 'already-member':
return 'You are already a member of this organization or workspace.'
case 'already-in-organization':
return 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
case 'invalid-invitation':
return 'This invitation is invalid or no longer exists.'
case 'missing-invitation-id':
return 'The invitation link is missing required information. Please use the original invitation link.'
case 'server-error':
return 'An unexpected error occurred while processing your invitation. Please try again later.'
default:
return 'An unknown error occurred while processing your invitation.'
}
}

View File

@@ -3,7 +3,6 @@
import { useEffect, useState } from 'react'
import { formatDistanceToNow } from 'date-fns'
import {
ArrowLeft,
ChartNoAxesColumn,
ChevronDown,
Globe,
@@ -16,6 +15,7 @@ import {
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import ReactMarkdown from 'react-markdown'
import {
Breadcrumb,
Button,
Copy,
Popover,
@@ -267,13 +267,13 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
}
}
const handleBack = () => {
if (isWorkspaceContext) {
router.back()
} else {
router.push('/templates')
}
}
const breadcrumbItems = [
{
label: 'Templates',
href: isWorkspaceContext ? `/workspace/${workspaceId}/templates` : '/templates',
},
{ label: template?.name || 'Template' },
]
/**
* Intercepts wheel events over the workflow preview so that the page handles scrolling
* instead of the underlying canvas. We stop propagation in the capture phase to prevent
@@ -542,24 +542,15 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
}
return (
<div className={cn('flex min-h-screen flex-col', isWorkspaceContext && 'pl-64')}>
<div className={cn('flex flex-col', isWorkspaceContext ? 'h-full flex-1' : 'min-h-screen')}>
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
{/* Top bar with back button */}
<div className='flex items-center justify-between'>
{/* Back button */}
<button
onClick={handleBack}
className='flex items-center gap-[6px] font-medium text-[#ADADAD] text-[14px] transition-colors hover:text-white'
>
<ArrowLeft className='h-[14px] w-[14px]' />
<span>More Templates</span>
</button>
</div>
{/* Breadcrumb navigation */}
<Breadcrumb items={breadcrumbItems} />
{/* Template name and action buttons */}
<div className='mt-[24px] flex items-center justify-between'>
<h1 className='font-medium text-[18px]'>{template.name}</h1>
<div className='mt-[14px] flex items-center justify-between'>
<h1 className='font-medium text-[18px] text-[var(--text-primary)]'>{template.name}</h1>
{/* Action buttons */}
<div className='flex items-center gap-[8px]'>
@@ -706,7 +697,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
{/* Template tagline */}
{template.details?.tagline && (
<p className='mt-[4px] font-medium text-[#888888] text-[14px]'>
<p className='mt-[4px] font-medium text-[14px] text-[var(--text-tertiary)]'>
{template.details.tagline}
</p>
)}
@@ -718,18 +709,22 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
onClick={handleStarToggle}
className={cn(
'h-[14px] w-[14px] cursor-pointer transition-colors',
template.isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
template.isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[var(--text-muted)]',
starTemplate.isPending && 'opacity-50'
)}
/>
<span className='font-medium text-[#888888] text-[14px]'>{template.stars || 0}</span>
<span className='font-medium text-[14px] text-[var(--text-muted)]'>
{template.stars || 0}
</span>
{/* Users icon and count */}
<ChartNoAxesColumn className='h-[16px] w-[16px] text-[#888888]' />
<span className='font-medium text-[#888888] text-[14px]'>{template.views}</span>
<ChartNoAxesColumn className='h-[16px] w-[16px] text-[var(--text-muted)]' />
<span className='font-medium text-[14px] text-[var(--text-muted)]'>
{template.views}
</span>
{/* Vertical divider */}
<div className='mx-[4px] mb-[-1.5px] h-[18px] w-[1.25px] rounded-full bg-[#3A3A3A]' />
<div className='mx-[4px] mb-[-1.5px] h-[18px] w-[1.25px] rounded-full bg-[var(--border)]' />
{/* Creator profile pic */}
{template.creator?.profileImageUrl ? (
@@ -741,13 +736,13 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
/>
</div>
) : (
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-full bg-[#4A4A4A]'>
<User className='h-[14px] w-[14px] text-[#888888]' />
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-full bg-[var(--surface-elevated)]'>
<User className='h-[14px] w-[14px] text-[var(--text-muted)]' />
</div>
)}
{/* Creator name */}
<div className='flex items-center gap-[4px]'>
<span className='font-medium text-[#8B8B8B] text-[14px]'>
<span className='font-medium text-[14px] text-[var(--text-muted)]'>
{template.creator?.name || 'Unknown'}
</span>
{template.creator?.verified && <VerifiedBadge size='md' />}
@@ -757,7 +752,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
{/* Credentials needed */}
{Array.isArray(template.requiredCredentials) &&
template.requiredCredentials.length > 0 && (
<p className='mt-[12px] font-medium text-[#888888] text-[12px]'>
<p className='mt-[12px] font-medium text-[12px] text-[var(--text-muted)]'>
Credentials needed:{' '}
{template.requiredCredentials
.map((cred: CredentialRequirement) => {
@@ -783,7 +778,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
{/* Last updated overlay */}
{template.updatedAt && (
<div className='pointer-events-none absolute right-[12px] bottom-[12px] rounded-[4px] bg-[var(--bg)]/80 px-[8px] py-[4px] backdrop-blur-sm'>
<span className='font-medium text-[#8B8B8B] text-[12px]'>
<span className='font-medium text-[12px] text-[var(--text-muted)]'>
Last updated{' '}
{formatDistanceToNow(new Date(template.updatedAt), {
addSuffix: true,
@@ -910,8 +905,8 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
/>
</div>
) : (
<div className='flex h-[48px] w-[48px] flex-shrink-0 items-center justify-center rounded-full bg-[#4A4A4A]'>
<User className='h-[24px] w-[24px] text-[#888888]' />
<div className='flex h-[48px] w-[48px] flex-shrink-0 items-center justify-center rounded-full bg-[var(--surface-elevated)]'>
<User className='h-[24px] w-[24px] text-[var(--text-muted)]' />
</div>
)}
@@ -932,7 +927,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
href={template.creator.details.websiteUrl}
target='_blank'
rel='noopener noreferrer'
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
className='flex items-center text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
aria-label='Website'
>
<Globe className='h-[14px] w-[14px]' />
@@ -943,7 +938,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
href={template.creator.details.xUrl}
target='_blank'
rel='noopener noreferrer'
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
className='flex items-center text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
aria-label='X (Twitter)'
>
<svg
@@ -960,7 +955,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
href={template.creator.details.linkedinUrl}
target='_blank'
rel='noopener noreferrer'
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
className='flex items-center text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
aria-label='LinkedIn'
>
<Linkedin className='h-[14px] w-[14px]' />
@@ -969,7 +964,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
{template.creator.details?.contactEmail && (
<a
href={`mailto:${template.creator.details.contactEmail}`}
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
className='flex items-center text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
aria-label='Email'
>
<Mail className='h-[14px] w-[14px]' />

View File

@@ -199,7 +199,7 @@ function TemplateCardInner({
>
<div
ref={previewRef}
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
className='pointer-events-none h-[180px] w-full cursor-pointer overflow-hidden rounded-[6px]'
>
{normalizedState && isInView ? (
<WorkflowPreview

View File

@@ -135,15 +135,15 @@ export default function Templates({
return (
<div className='flex h-[100vh] flex-col'>
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[28px] pb-[24px]'>
<div>
<div className='flex items-start gap-[12px]'>
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#7A5F11] bg-[#514215]'>
<Layout className='h-[14px] w-[14px] text-[#FBBC04]' />
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#1E3A5A] bg-[#0F2A3D]'>
<Layout className='h-[14px] w-[14px] text-[#60A5FA]' />
</div>
<h1 className='font-medium text-[18px]'>Templates</h1>
</div>
<p className='mt-[10px] font-base text-[#888888] text-[14px]'>
<p className='mt-[10px] text-[14px] text-[var(--text-tertiary)]'>
Grab a template and start building, or make one from scratch.
</p>
</div>
@@ -178,15 +178,13 @@ export default function Templates({
</div>
</div>
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
<div className='mt-[24px] grid grid-cols-1 gap-x-[20px] gap-y-[40px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading ? (
Array.from({ length: 8 }).map((_, index) => (
<TemplateCardSkeleton key={`skeleton-${index}`} />
))
) : filteredTemplates.length === 0 ? (
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-muted-foreground text-sm'>{emptyState.title}</p>
<p className='mt-1 text-muted-foreground/70 text-xs'>{emptyState.description}</p>

View File

@@ -2,15 +2,16 @@
import { useRef, useState } from 'react'
import { AlertCircle, Loader2 } from 'lucide-react'
import { Button, Textarea } from '@/components/emcn'
import {
Button,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn/components/modal/modal'
import { Label } from '@/components/ui/label'
Textarea,
} from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import type { ChunkData, DocumentData } from '@/stores/knowledge/store'
@@ -119,24 +120,12 @@ export function CreateChunkModal({
return (
<>
<Modal open={open} onOpenChange={handleCloseAttempt}>
<ModalContent className='h-[74vh] sm:max-w-[600px]'>
<ModalContent size='lg'>
<ModalHeader>Create Chunk</ModalHeader>
<form className='flex min-h-0 flex-1 flex-col'>
<ModalBody>
<div className='space-y-[12px]'>
{/* Document Info Section */}
<div className='flex items-center gap-3 rounded-lg border p-4'>
<div className='min-w-0 flex-1'>
<p className='font-medium text-[var(--text-primary)] text-sm'>
{document?.filename || 'Unknown Document'}
</p>
<p className='text-[var(--text-tertiary)] text-xs'>
Adding chunk to this document
</p>
</div>
</div>
<form>
<ModalBody className='!pb-[16px]'>
<div className='flex flex-col gap-[8px]'>
{/* Error Display */}
{error && (
<div className='flex items-center gap-2 rounded-md border border-[var(--text-error)]/50 bg-[var(--text-error)]/10 p-3'>
@@ -146,22 +135,15 @@ export function CreateChunkModal({
)}
{/* Content Input Section */}
<div className='space-y-[8px]'>
<Label
htmlFor='content'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Chunk Content
</Label>
<Textarea
id='content'
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder='Enter the content for this chunk...'
rows={10}
disabled={isCreating}
/>
</div>
<Label htmlFor='content'>Chunk</Label>
<Textarea
id='content'
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder='Enter the content for this chunk...'
rows={12}
disabled={isCreating}
/>
</div>
</ModalBody>
@@ -196,7 +178,7 @@ export function CreateChunkModal({
{/* Unsaved Changes Alert */}
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Discard Changes</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>

View File

@@ -1,9 +1,7 @@
'use client'
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { createLogger } from '@/lib/logs/console/logger'
import type { ChunkData } from '@/stores/knowledge/store'
@@ -68,7 +66,7 @@ export function DeleteChunkModal({
return (
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Delete Chunk</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
@@ -86,17 +84,7 @@ export function DeleteChunkModal({
disabled={isDeleting}
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
>
{isDeleting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Deleting...
</>
) : (
<>
<Trash className='mr-2 h-4 w-4' />
Delete
</>
)}
{isDeleting ? <>Deleting...</> : <>Delete</>}
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -1,80 +0,0 @@
'use client'
import { Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/emcn'
import {
ChunkTableSkeleton,
KnowledgeHeader,
} from '@/app/workspace/[workspaceId]/knowledge/components'
interface DocumentLoadingProps {
knowledgeBaseId: string
knowledgeBaseName: string
documentName: string
}
export function DocumentLoading({
knowledgeBaseId,
knowledgeBaseName,
documentName,
}: DocumentLoadingProps) {
const params = useParams()
const workspaceId = params?.workspaceId as string
const breadcrumbs = [
{
id: 'knowledge-root',
label: 'Knowledge',
href: `/workspace/${workspaceId}/knowledge`,
},
{
id: `knowledge-base-${knowledgeBaseId}`,
label: knowledgeBaseName,
href: `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`,
},
{
id: `document-${knowledgeBaseId}-${documentName}`,
label: documentName,
},
]
return (
<div className='flex h-[100vh] flex-col pl-64'>
{/* Header with Breadcrumbs */}
<KnowledgeHeader breadcrumbs={breadcrumbs} />
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Main Content */}
<div className='flex-1 overflow-auto'>
<div className='px-6 pb-6'>
{/* Search Section */}
<div className='mb-4 flex items-center justify-between pt-1'>
<div className='relative max-w-md'>
<div className='relative flex items-center'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
<input
type='text'
placeholder='Search chunks...'
disabled
className='h-10 w-full rounded-md border bg-background px-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
/>
</div>
</div>
<Button disabled variant='primary' className='flex items-center gap-1'>
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
<span>Create Chunk</span>
</Button>
</div>
{/* Table container */}
<ChunkTableSkeleton isSidebarCollapsed={false} rowCount={8} />
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,649 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import {
Button,
Combobox,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Trash,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { MAX_TAG_SLOTS, TAG_SLOTS, type TagSlot } from '@/lib/knowledge/constants'
import type { DocumentTag } from '@/lib/knowledge/tags/types'
import { createLogger } from '@/lib/logs/console/logger'
import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/use-knowledge-base-tag-definitions'
import { useNextAvailableSlot } from '@/hooks/use-next-available-slot'
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/use-tag-definitions'
import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
const logger = createLogger('DocumentTagsModal')
interface DocumentTagsModalProps {
open: boolean
onOpenChange: (open: boolean) => void
knowledgeBaseId: string
documentId: string
documentData: DocumentData | null
onDocumentUpdate?: (updates: Record<string, string>) => void
}
export function DocumentTagsModal({
open,
onOpenChange,
knowledgeBaseId,
documentId,
documentData,
onDocumentUpdate,
}: DocumentTagsModalProps) {
const { updateDocument: updateDocumentInStore } = useKnowledgeStore()
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const { getNextAvailableSlot: getServerNextSlot } = useNextAvailableSlot(knowledgeBaseId)
const { saveTagDefinitions, tagDefinitions, fetchTagDefinitions } = documentTagHook
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
const [documentTags, setDocumentTags] = useState<DocumentTag[]>([])
const [editingTagIndex, setEditingTagIndex] = useState<number | null>(null)
const [isCreatingTag, setIsCreatingTag] = useState(false)
const [isSavingTag, setIsSavingTag] = useState(false)
const [editTagForm, setEditTagForm] = useState({
displayName: '',
fieldType: 'text',
value: '',
})
const buildDocumentTags = useCallback((docData: DocumentData, definitions: TagDefinition[]) => {
const tags: DocumentTag[] = []
TAG_SLOTS.forEach((slot) => {
const value = docData[slot] as string | null | undefined
const definition = definitions.find((def) => def.tagSlot === slot)
if (value?.trim() && definition) {
tags.push({
slot,
displayName: definition.displayName,
fieldType: definition.fieldType,
value: value.trim(),
})
}
})
return tags
}, [])
const handleTagsChange = useCallback((newTags: DocumentTag[]) => {
setDocumentTags(newTags)
}, [])
const handleSaveDocumentTags = useCallback(
async (tagsToSave: DocumentTag[]) => {
if (!documentData) return
try {
const tagData: Record<string, string> = {}
TAG_SLOTS.forEach((slot) => {
tagData[slot] = ''
})
tagsToSave.forEach((tag) => {
if (tag.value.trim()) {
tagData[tag.slot] = tag.value.trim()
}
})
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(tagData),
})
if (!response.ok) {
throw new Error('Failed to update document tags')
}
updateDocumentInStore(knowledgeBaseId, documentId, tagData)
onDocumentUpdate?.(tagData)
await fetchTagDefinitions()
} catch (error) {
logger.error('Error updating document tags:', error)
throw error
}
},
[
documentData,
knowledgeBaseId,
documentId,
updateDocumentInStore,
fetchTagDefinitions,
onDocumentUpdate,
]
)
const handleRemoveTag = async (index: number) => {
const updatedTags = documentTags.filter((_, i) => i !== index)
handleTagsChange(updatedTags)
try {
await handleSaveDocumentTags(updatedTags)
} catch (error) {
logger.error('Error removing tag:', error)
}
}
const startEditingTag = (index: number) => {
const tag = documentTags[index]
setEditingTagIndex(index)
setEditTagForm({
displayName: tag.displayName,
fieldType: tag.fieldType,
value: tag.value,
})
setIsCreatingTag(false)
}
const openTagCreator = () => {
setEditingTagIndex(null)
setEditTagForm({
displayName: '',
fieldType: 'text',
value: '',
})
setIsCreatingTag(true)
}
const cancelEditingTag = () => {
setEditTagForm({
displayName: '',
fieldType: 'text',
value: '',
})
setEditingTagIndex(null)
setIsCreatingTag(false)
}
const hasTagNameConflict = (name: string) => {
if (!name.trim()) return false
return documentTags.some((tag, index) => {
if (editingTagIndex !== null && index === editingTagIndex) {
return false
}
return tag.displayName.toLowerCase() === name.trim().toLowerCase()
})
}
const availableDefinitions = kbTagDefinitions.filter((def) => {
return !documentTags.some(
(tag) => tag.displayName.toLowerCase() === def.displayName.toLowerCase()
)
})
const tagNameOptions = availableDefinitions.map((def) => ({
label: def.displayName,
value: def.displayName,
}))
const saveDocumentTag = async () => {
if (!editTagForm.displayName.trim() || !editTagForm.value.trim()) return
const formData = { ...editTagForm }
const currentEditingIndex = editingTagIndex
const originalTag = currentEditingIndex !== null ? documentTags[currentEditingIndex] : null
setEditingTagIndex(null)
setIsCreatingTag(false)
setIsSavingTag(true)
try {
let targetSlot: string
if (currentEditingIndex !== null && originalTag) {
targetSlot = originalTag.slot
} else {
const existingDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === formData.displayName.toLowerCase()
)
if (existingDefinition) {
targetSlot = existingDefinition.tagSlot
} else {
const serverSlot = await getServerNextSlot(formData.fieldType)
if (!serverSlot) {
throw new Error(`No available slots for new tag of type '${formData.fieldType}'`)
}
targetSlot = serverSlot
}
}
let updatedTags: DocumentTag[]
if (currentEditingIndex !== null) {
updatedTags = [...documentTags]
updatedTags[currentEditingIndex] = {
...updatedTags[currentEditingIndex],
displayName: formData.displayName,
fieldType: formData.fieldType,
value: formData.value,
}
} else {
const newTag: DocumentTag = {
slot: targetSlot,
displayName: formData.displayName,
fieldType: formData.fieldType,
value: formData.value,
}
updatedTags = [...documentTags, newTag]
}
handleTagsChange(updatedTags)
if (currentEditingIndex !== null && originalTag) {
const currentDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === originalTag.displayName.toLowerCase()
)
if (currentDefinition) {
const updatedDefinition: TagDefinitionInput = {
displayName: formData.displayName,
fieldType: currentDefinition.fieldType,
tagSlot: currentDefinition.tagSlot,
_originalDisplayName: originalTag.displayName,
}
if (saveTagDefinitions) {
await saveTagDefinitions([updatedDefinition])
}
await refreshTagDefinitions()
}
} else {
const existingDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === formData.displayName.toLowerCase()
)
if (!existingDefinition) {
const newDefinition: TagDefinitionInput = {
displayName: formData.displayName,
fieldType: formData.fieldType,
tagSlot: targetSlot as TagSlot,
}
if (saveTagDefinitions) {
await saveTagDefinitions([newDefinition])
}
await refreshTagDefinitions()
}
}
await handleSaveDocumentTags(updatedTags)
setEditTagForm({
displayName: '',
fieldType: 'text',
value: '',
})
} catch (error) {
logger.error('Error saving tag:', error)
} finally {
setIsSavingTag(false)
}
}
const isTagEditing = editingTagIndex !== null || isCreatingTag
const tagNameConflict = hasTagNameConflict(editTagForm.displayName)
const hasTagChanges = () => {
if (editingTagIndex === null) return true
const originalTag = documentTags[editingTagIndex]
if (!originalTag) return true
return (
originalTag.displayName !== editTagForm.displayName ||
originalTag.value !== editTagForm.value ||
originalTag.fieldType !== editTagForm.fieldType
)
}
const canSaveTag =
editTagForm.displayName.trim() &&
editTagForm.value.trim() &&
!tagNameConflict &&
hasTagChanges()
const canAddNewTag = kbTagDefinitions.length < MAX_TAG_SLOTS || availableDefinitions.length > 0
useEffect(() => {
if (documentData && tagDefinitions && !isSavingTag) {
const rebuiltTags = buildDocumentTags(documentData, tagDefinitions)
setDocumentTags(rebuiltTags)
}
}, [documentData, tagDefinitions, buildDocumentTags, isSavingTag])
const handleClose = (openState: boolean) => {
if (!openState) {
setIsCreatingTag(false)
setEditingTagIndex(null)
setEditTagForm({
displayName: '',
fieldType: 'text',
value: '',
})
}
onOpenChange(openState)
}
return (
<Modal open={open} onOpenChange={handleClose}>
<ModalContent>
<ModalHeader>
<div className='flex items-center justify-between'>
<span>Document Tags</span>
</div>
</ModalHeader>
<ModalBody className='!pb-[16px]'>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[8px]'>
<Label>
Tags{' '}
<span className='pl-[6px] text-[var(--text-tertiary)]'>
{documentTags.length}/{MAX_TAG_SLOTS} slots used
</span>
</Label>
{documentTags.length === 0 && !isCreatingTag && (
<div className='rounded-[6px] border p-[16px] text-center'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
No tags added yet. Add tags to help organize this document.
</p>
</div>
)}
{documentTags.map((tag, index) => (
<div key={index} className='space-y-[8px]'>
<div
className='flex cursor-pointer items-center gap-2 rounded-[4px] border p-[8px] hover:bg-[var(--surface-2)]'
onClick={() => startEditingTag(index)}
>
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
{tag.displayName}
</span>
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
<span className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-muted)]'>
{tag.value}
</span>
<div className='flex flex-shrink-0 items-center gap-1'>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleRemoveTag(index)
}}
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
>
<Trash className='h-3 w-3' />
</Button>
</div>
</div>
{editingTagIndex === index && (
<div className='space-y-[8px] rounded-[6px] border p-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor={`tagName-${index}`}>Tag Name</Label>
{availableDefinitions.length > 0 ? (
<Combobox
id={`tagName-${index}`}
options={tagNameOptions}
value={editTagForm.displayName}
selectedValue={editTagForm.displayName}
onChange={(value) => {
const def = kbTagDefinitions.find(
(d) => d.displayName.toLowerCase() === value.toLowerCase()
)
setEditTagForm({
...editTagForm,
displayName: value,
fieldType: def?.fieldType || 'text',
})
}}
placeholder='Enter or select tag name'
editable={true}
className={cn(tagNameConflict && 'border-[var(--text-error)]')}
/>
) : (
<Input
id={`tagName-${index}`}
value={editTagForm.displayName}
onChange={(e) =>
setEditTagForm({ ...editTagForm, displayName: e.target.value })
}
placeholder='Enter tag name'
className={cn(tagNameConflict && 'border-[var(--text-error)]')}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
)}
{tagNameConflict && (
<span className='text-[11px] text-[var(--text-error)]'>
A tag with this name already exists
</span>
)}
</div>
{/* Type selector commented out - only "text" type is currently supported
<div className='flex flex-col gap-[8px]'>
<Label htmlFor={`tagType-${index}`}>Type</Label>
<Input id={`tagType-${index}`} value='Text' disabled className='capitalize' />
</div>
*/}
<div className='flex flex-col gap-[8px]'>
<Label htmlFor={`tagValue-${index}`}>Value</Label>
<Input
id={`tagValue-${index}`}
value={editTagForm.value}
onChange={(e) =>
setEditTagForm({ ...editTagForm, value: e.target.value })
}
placeholder='Enter tag value'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
</div>
<div className='flex gap-[8px]'>
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
Cancel
</Button>
<Button
variant='primary'
onClick={saveDocumentTag}
className='flex-1'
disabled={!canSaveTag}
>
Save Changes
</Button>
</div>
</div>
)}
</div>
))}
{!isTagEditing && (
<Button
variant='default'
onClick={openTagCreator}
disabled={!canAddNewTag}
className='w-full'
>
Add Tag
</Button>
)}
{isCreatingTag && (
<div className='space-y-[8px] rounded-[6px] border p-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='newTagName'>Tag Name</Label>
{tagNameOptions.length > 0 ? (
<Combobox
id='newTagName'
options={tagNameOptions}
value={editTagForm.displayName}
selectedValue={editTagForm.displayName}
onChange={(value) => {
const def = kbTagDefinitions.find(
(d) => d.displayName.toLowerCase() === value.toLowerCase()
)
setEditTagForm({
...editTagForm,
displayName: value,
fieldType: def?.fieldType || 'text',
})
}}
placeholder='Enter or select tag name'
editable={true}
className={cn(tagNameConflict && 'border-[var(--text-error)]')}
/>
) : (
<Input
id='newTagName'
value={editTagForm.displayName}
onChange={(e) =>
setEditTagForm({ ...editTagForm, displayName: e.target.value })
}
placeholder='Enter tag name'
className={cn(tagNameConflict && 'border-[var(--text-error)]')}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
)}
{tagNameConflict && (
<span className='text-[11px] text-[var(--text-error)]'>
A tag with this name already exists
</span>
)}
</div>
{/* Type selector commented out - only "text" type is currently supported
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='newTagType'>Type</Label>
<Input id='newTagType' value='Text' disabled className='capitalize' />
</div>
*/}
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='newTagValue'>Value</Label>
<Input
id='newTagValue'
value={editTagForm.value}
onChange={(e) => setEditTagForm({ ...editTagForm, value: e.target.value })}
placeholder='Enter tag value'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
</div>
{kbTagDefinitions.length >= MAX_TAG_SLOTS &&
!kbTagDefinitions.find(
(def) =>
def.displayName.toLowerCase() === editTagForm.displayName.toLowerCase()
) && (
<div className='rounded-[4px] border border-amber-500/50 bg-amber-500/10 p-[8px]'>
<p className='text-[11px] text-amber-600 dark:text-amber-400'>
Maximum tag definitions reached. You can still use existing tag
definitions, but cannot create new ones.
</p>
</div>
)}
<div className='flex gap-[8px]'>
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
Cancel
</Button>
<Button
variant='primary'
onClick={saveDocumentTag}
className='flex-1'
disabled={
!canSaveTag ||
isSavingTag ||
(kbTagDefinitions.length >= MAX_TAG_SLOTS &&
!kbTagDefinitions.find(
(def) =>
def.displayName.toLowerCase() ===
editTagForm.displayName.toLowerCase()
))
}
>
{isSavingTag ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Creating...
</>
) : (
'Create Tag'
)}
</Button>
</div>
</div>
)}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => handleClose(false)}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { AlertCircle, ChevronDown, ChevronUp, Loader2, X } from 'lucide-react'
import {
Button,
@@ -171,13 +172,15 @@ export function EditChunkModal({
return (
<>
<Modal open={isOpen} onOpenChange={handleCloseAttempt}>
<ModalContent className='h-[74vh] sm:max-w-[600px]'>
<ModalContent size='lg'>
<div className='flex items-center justify-between px-[16px] py-[10px]'>
<div className='flex items-center gap-3'>
<span className='font-medium text-[16px] text-[var(--text-primary)]'>Edit Chunk</span>
<DialogPrimitive.Title className='font-medium text-[16px] text-[var(--text-primary)]'>
Edit Chunk #{chunk.chunkIndex}
</DialogPrimitive.Title>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
{/* Navigation Controls */}
<div className='flex items-center gap-1'>
<div className='flex items-center gap-[6px]'>
<Tooltip.Root>
<Tooltip.Trigger
asChild
@@ -188,9 +191,9 @@ export function EditChunkModal({
variant='ghost'
onClick={() => handleNavigate('prev')}
disabled={!canNavigatePrev || isNavigating || isSaving}
className='h-8 w-8 p-0'
className='h-[16px] w-[16px] p-0'
>
<ChevronUp className='h-4 w-4' />
<ChevronUp className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
@@ -209,9 +212,9 @@ export function EditChunkModal({
variant='ghost'
onClick={() => handleNavigate('next')}
disabled={!canNavigateNext || isNavigating || isSaving}
className='h-8 w-8 p-0'
className='h-[16px] w-[16px] p-0'
>
<ChevronDown className='h-4 w-4' />
<ChevronDown className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
@@ -222,29 +225,21 @@ export function EditChunkModal({
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
<Button variant='ghost' className='h-[16px] w-[16px] p-0' onClick={handleCloseAttempt}>
<X className='h-[16px] w-[16px]' />
<span className='sr-only'>Close</span>
</Button>
<Button
variant='ghost'
className='h-[16px] w-[16px] p-0'
onClick={handleCloseAttempt}
>
<X className='h-[16px] w-[16px]' />
<span className='sr-only'>Close</span>
</Button>
</div>
</div>
<form className='flex min-h-0 flex-1 flex-col'>
<ModalBody>
<div className='space-y-[12px]'>
{/* Document Info Section */}
<div className='flex items-center gap-3 rounded-lg border p-4'>
<div className='min-w-0 flex-1'>
<p className='font-medium text-[var(--text-primary)] text-sm'>
{document?.filename || 'Unknown Document'}
</p>
<p className='text-[var(--text-tertiary)] text-xs'>
Editing chunk #{chunk.chunkIndex} Page {currentPage} of {totalPages}
</p>
</div>
</div>
<form>
<ModalBody className='!pb-[16px]'>
<div className='flex flex-col gap-[8px]'>
{/* Error Display */}
{error && (
<div className='flex items-center gap-2 rounded-md border border-[var(--text-error)]/50 bg-[var(--text-error)]/10 p-3'>
@@ -254,25 +249,18 @@ export function EditChunkModal({
)}
{/* Content Input Section */}
<div className='space-y-[8px]'>
<Label
htmlFor='content'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Chunk Content
</Label>
<Textarea
id='content'
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
placeholder={
userPermissions.canEdit ? 'Enter chunk content...' : 'Read-only view'
}
rows={10}
disabled={isSaving || isNavigating || !userPermissions.canEdit}
readOnly={!userPermissions.canEdit}
/>
</div>
<Label htmlFor='content'>Chunk</Label>
<Textarea
id='content'
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
placeholder={
userPermissions.canEdit ? 'Enter chunk content...' : 'Read-only view'
}
rows={20}
disabled={isSaving || isNavigating || !userPermissions.canEdit}
readOnly={!userPermissions.canEdit}
/>
</div>
</ModalBody>
@@ -298,7 +286,7 @@ export function EditChunkModal({
Saving...
</>
) : (
'Save Changes'
'Save'
)}
</Button>
)}
@@ -309,7 +297,7 @@ export function EditChunkModal({
{/* Unsaved Changes Alert */}
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>

View File

@@ -1,4 +1,4 @@
export { CreateChunkModal } from './create-chunk-modal/create-chunk-modal'
export { DeleteChunkModal } from './delete-chunk-modal/delete-chunk-modal'
export { DocumentLoading } from './document-loading'
export { DocumentTagsModal } from './document-tags-modal/document-tags-modal'
export { EditChunkModal } from './edit-chunk-modal/edit-chunk-modal'

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
import { motion } from 'framer-motion'
import { Circle, CircleOff, Trash2 } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Circle, CircleOff } from 'lucide-react'
import { Button, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -42,23 +41,22 @@ export function ActionBar({
transition={{ duration: 0.2 }}
className={cn('-translate-x-1/2 fixed bottom-6 left-1/2 z-50 transform', className)}
>
<div className='flex items-center gap-3 rounded-lg border border-gray-200 bg-background px-4 py-2 shadow-sm dark:border-gray-800'>
<span className='text-gray-500 text-sm'>{selectedCount} selected</span>
<div className='flex items-center gap-[8px] rounded-[10px] border border-[var(--border-strong)] bg-[var(--surface-1)] p-[8px]'>
<span className='px-[4px] text-[13px] text-[var(--text-muted)]'>
{selectedCount} selected
</span>
<div className='h-4 w-px bg-gray-200 dark:bg-gray-800' />
<div className='flex items-center gap-1'>
<div className='flex items-center gap-[5px]'>
{showEnableButton && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={onEnable}
disabled={isLoading}
className='text-gray-500 hover:text-gray-700'
className='hover:!text-[var(--text-inverse)] h-[28px] w-[28px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
>
<Circle className='h-4 w-4' />
<Circle className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
@@ -72,12 +70,11 @@ export function ActionBar({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={onDisable}
disabled={isLoading}
className='text-gray-500 hover:text-gray-700'
className='hover:!text-[var(--text-inverse)] h-[28px] w-[28px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
>
<CircleOff className='h-4 w-4' />
<CircleOff className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
@@ -91,12 +88,11 @@ export function ActionBar({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={onDelete}
disabled={isLoading}
className='text-gray-500 hover:text-red-600'
className='hover:!text-[var(--text-inverse)] h-[28px] w-[28px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
>
<Trash2 className='h-4 w-4' />
<Trash2 className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Delete items</Tooltip.Content>

View File

@@ -0,0 +1,366 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { AlertCircle, Loader2, RotateCcw, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
const logger = createLogger('AddDocumentsModal')
interface FileWithPreview extends File {
preview: string
}
interface AddDocumentsModalProps {
open: boolean
onOpenChange: (open: boolean) => void
knowledgeBaseId: string
chunkingConfig?: {
maxSize: number
minSize: number
overlap: number
}
onUploadComplete?: () => void
}
export function AddDocumentsModal({
open,
onOpenChange,
knowledgeBaseId,
chunkingConfig,
onUploadComplete,
}: AddDocumentsModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const fileInputRef = useRef<HTMLInputElement>(null)
const [files, setFiles] = useState<FileWithPreview[]>([])
const [fileError, setFileError] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [dragCounter, setDragCounter] = useState(0)
const [retryingIndexes, setRetryingIndexes] = useState<Set<number>>(new Set())
const { isUploading, uploadProgress, uploadFiles, clearError } = useKnowledgeUpload({
workspaceId,
onUploadComplete: () => {
logger.info(`Successfully uploaded ${files.length} files`)
onUploadComplete?.()
handleClose()
},
})
useEffect(() => {
return () => {
files.forEach((file) => {
if (file.preview) {
URL.revokeObjectURL(file.preview)
}
})
}
}, [files])
useEffect(() => {
if (open) {
setFiles([])
setFileError(null)
setIsDragging(false)
setDragCounter(0)
setRetryingIndexes(new Set())
clearError()
}
}, [open, clearError])
const handleClose = () => {
if (isUploading) return
setFiles([])
setFileError(null)
clearError()
setIsDragging(false)
setDragCounter(0)
setRetryingIndexes(new Set())
onOpenChange(false)
}
const processFiles = async (fileList: FileList | File[]) => {
setFileError(null)
if (!fileList || fileList.length === 0) return
try {
const newFiles: FileWithPreview[] = []
let hasError = false
for (const file of Array.from(fileList)) {
const validationError = validateKnowledgeBaseFile(file)
if (validationError) {
setFileError(validationError)
hasError = true
continue
}
const fileWithPreview = Object.assign(file, {
preview: URL.createObjectURL(file),
}) as FileWithPreview
newFiles.push(fileWithPreview)
}
if (!hasError && newFiles.length > 0) {
setFiles((prev) => [...prev, ...newFiles])
}
} catch (error) {
logger.error('Error processing files:', error)
setFileError('An error occurred while processing files. Please try again.')
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
await processFiles(e.target.files)
}
}
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => {
const newCount = prev + 1
if (newCount === 1) {
setIsDragging(true)
}
return newCount
})
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => {
const newCount = prev - 1
if (newCount === 0) {
setIsDragging(false)
}
return newCount
})
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
setDragCounter(0)
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
await processFiles(e.dataTransfer.files)
}
}
const removeFile = (index: number) => {
setFiles((prev) => {
URL.revokeObjectURL(prev[index].preview)
return prev.filter((_, i) => i !== index)
})
}
const handleRetryFile = async (index: number) => {
const fileToRetry = files[index]
if (!fileToRetry) return
setRetryingIndexes((prev) => new Set(prev).add(index))
try {
await uploadFiles([fileToRetry], knowledgeBaseId, {
chunkSize: chunkingConfig?.maxSize || 1024,
minCharactersPerChunk: chunkingConfig?.minSize || 1,
chunkOverlap: chunkingConfig?.overlap || 200,
recipe: 'default',
})
removeFile(index)
} catch (error) {
logger.error('Error retrying file upload:', error)
} finally {
setRetryingIndexes((prev) => {
const newSet = new Set(prev)
newSet.delete(index)
return newSet
})
}
}
const handleUpload = async () => {
if (files.length === 0) return
try {
await uploadFiles(files, knowledgeBaseId, {
chunkSize: chunkingConfig?.maxSize || 1024,
minCharactersPerChunk: chunkingConfig?.minSize || 1,
chunkOverlap: chunkingConfig?.overlap || 200,
recipe: 'default',
})
} catch (error) {
logger.error('Error uploading files:', error)
}
}
return (
<Modal open={open} onOpenChange={handleClose}>
<ModalContent>
<ModalHeader>Add Documents</ModalHeader>
<ModalBody className='!pb-[16px]'>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[12px]'>
{fileError && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{fileError}</AlertDescription>
</Alert>
)}
<div className='flex flex-col gap-[8px]'>
<Label>Upload Documents</Label>
<Button
type='button'
variant='default'
onClick={() => fileInputRef.current?.click()}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--c-575757)] border-dashed py-[10px]',
isDragging && 'border-[var(--brand-primary-hex)]'
)}
>
<input
ref={fileInputRef}
type='file'
accept={ACCEPT_ATTRIBUTE}
onChange={handleFileChange}
className='hidden'
multiple
/>
<div className='flex flex-col gap-[2px] text-center'>
<span className='text-[var(--text-primary)]'>
{isDragging ? 'Drop files here' : 'Drop files here or click to browse'}
</span>
<span className='text-[11px] text-[var(--text-tertiary)]'>
PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML (max 100MB each)
</span>
</div>
</Button>
</div>
{files.length > 0 && (
<div className='space-y-2'>
<Label>Selected Files</Label>
<div className='space-y-2'>
{files.map((file, index) => {
const fileStatus = uploadProgress.fileStatuses?.[index]
const isFailed = fileStatus?.status === 'failed'
const isRetrying = retryingIndexes.has(index)
const isProcessing = fileStatus?.status === 'uploading' || isRetrying
return (
<div
key={index}
className='flex items-center gap-2 rounded-[4px] border p-[8px]'
>
{isFailed && !isRetrying && (
<AlertCircle className='h-4 w-4 flex-shrink-0 text-[var(--text-error)]' />
)}
<span
className={cn(
'min-w-0 flex-1 truncate text-[12px]',
isFailed && !isRetrying && 'text-[var(--text-error)]'
)}
title={file.name}
>
{file.name}
</span>
<span className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
{formatFileSize(file.size)}
</span>
<div className='flex flex-shrink-0 items-center gap-1'>
{isFailed && !isRetrying && (
<Button
type='button'
variant='ghost'
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
onClick={() => handleRetryFile(index)}
disabled={isUploading}
>
<RotateCcw className='h-3.5 w-3.5' />
</Button>
)}
{isProcessing ? (
<Loader2 className='h-4 w-4 animate-spin text-[var(--text-muted)]' />
) : (
<Button
type='button'
variant='ghost'
className='h-4 w-4 p-0'
onClick={() => removeFile(index)}
disabled={isUploading}
>
<X className='h-3.5 w-3.5' />
</Button>
)}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleClose} type='button' disabled={isUploading}>
Cancel
</Button>
<Button
variant='primary'
type='button'
onClick={handleUpload}
disabled={files.length === 0 || isUploading}
>
{isUploading
? uploadProgress.stage === 'uploading'
? `Uploading ${uploadProgress.filesCompleted}/${uploadProgress.totalFiles}...`
: uploadProgress.stage === 'processing'
? 'Processing...'
: 'Uploading...'
: 'Upload'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,485 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import {
Button,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Trash,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
import { createLogger } from '@/lib/logs/console/logger'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/use-knowledge-base-tag-definitions'
const logger = createLogger('BaseTagsModal')
interface TagUsageData {
tagName: string
tagSlot: string
documentCount: number
documents: Array<{ id: string; name: string; tagValue: string }>
}
interface DocumentListProps {
documents: Array<{ id: string; name: string; tagValue: string }>
totalCount: number
}
/** Displays a list of documents affected by tag operations */
function DocumentList({ documents, totalCount }: DocumentListProps) {
const displayLimit = 5
const hasMore = totalCount > displayLimit
return (
<div className='rounded-[4px] border'>
<div className='max-h-[160px] overflow-y-auto'>
{documents.slice(0, displayLimit).map((doc) => {
const DocumentIcon = getDocumentIcon('', doc.name)
return (
<div
key={doc.id}
className='flex items-center gap-[8px] border-b p-[8px] last:border-b-0'
>
<DocumentIcon className='h-4 w-4 flex-shrink-0 text-[var(--text-muted)]' />
<span className='min-w-0 max-w-[120px] truncate text-[12px] text-[var(--text-primary)]'>
{doc.name}
</span>
{doc.tagValue && (
<>
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
<span className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-muted)]'>
{doc.tagValue}
</span>
</>
)}
</div>
)
})}
{hasMore && (
<div className='p-[8px] text-[11px] text-[var(--text-muted)]'>
and {totalCount - displayLimit} more documents
</div>
)}
</div>
</div>
)
}
interface BaseTagsModalProps {
open: boolean
onOpenChange: (open: boolean) => void
knowledgeBaseId: string
}
export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsModalProps) {
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false)
const [selectedTag, setSelectedTag] = useState<TagDefinition | null>(null)
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
const [isDeletingTag, setIsDeletingTag] = useState(false)
const [tagUsageData, setTagUsageData] = useState<TagUsageData[]>([])
const [isCreatingTag, setIsCreatingTag] = useState(false)
const [isSavingTag, setIsSavingTag] = useState(false)
const [createTagForm, setCreateTagForm] = useState({
displayName: '',
fieldType: 'text',
})
const fetchTagUsage = useCallback(async () => {
if (!knowledgeBaseId) return
try {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
if (!response.ok) {
throw new Error('Failed to fetch tag usage')
}
const result = await response.json()
if (result.success) {
setTagUsageData(result.data)
}
} catch (error) {
logger.error('Error fetching tag usage:', error)
}
}, [knowledgeBaseId])
useEffect(() => {
if (open) {
fetchTagUsage()
}
}, [open, fetchTagUsage])
const getTagUsage = (tagSlot: string): TagUsageData => {
return (
tagUsageData.find((usage) => usage.tagSlot === tagSlot) || {
tagName: '',
tagSlot,
documentCount: 0,
documents: [],
}
)
}
const handleDeleteTagClick = async (tag: TagDefinition) => {
setSelectedTag(tag)
await fetchTagUsage()
setDeleteTagDialogOpen(true)
}
const handleViewDocuments = async (tag: TagDefinition) => {
setSelectedTag(tag)
await fetchTagUsage()
setViewDocumentsDialogOpen(true)
}
const openTagCreator = () => {
setCreateTagForm({
displayName: '',
fieldType: 'text',
})
setIsCreatingTag(true)
}
const cancelCreatingTag = () => {
setCreateTagForm({
displayName: '',
fieldType: 'text',
})
setIsCreatingTag(false)
}
const hasTagNameConflict = (name: string) => {
if (!name.trim()) return false
return kbTagDefinitions.some(
(tag) => tag.displayName.toLowerCase() === name.trim().toLowerCase()
)
}
const tagNameConflict =
isCreatingTag && !isSavingTag && hasTagNameConflict(createTagForm.displayName)
const canSaveTag = () => {
return createTagForm.displayName.trim() && !hasTagNameConflict(createTagForm.displayName)
}
const saveTagDefinition = async () => {
if (!canSaveTag()) return
setIsSavingTag(true)
try {
const usedSlots = new Set(kbTagDefinitions.map((def) => def.tagSlot))
const availableSlot = (
['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
).find((slot) => !usedSlots.has(slot))
if (!availableSlot) {
throw new Error('No available tag slots')
}
const newTagDefinition = {
tagSlot: availableSlot,
displayName: createTagForm.displayName.trim(),
fieldType: createTagForm.fieldType,
}
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-definitions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newTagDefinition),
})
if (!response.ok) {
throw new Error('Failed to create tag definition')
}
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
setCreateTagForm({
displayName: '',
fieldType: 'text',
})
setIsCreatingTag(false)
} catch (error) {
logger.error('Error creating tag definition:', error)
} finally {
setIsSavingTag(false)
}
}
const confirmDeleteTag = async () => {
if (!selectedTag) return
setIsDeletingTag(true)
try {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/tag-definitions/${selectedTag.id}`,
{
method: 'DELETE',
}
)
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to delete tag definition: ${response.status} ${errorText}`)
}
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
setDeleteTagDialogOpen(false)
setSelectedTag(null)
} catch (error) {
logger.error('Error deleting tag definition:', error)
} finally {
setIsDeletingTag(false)
}
}
const selectedTagUsage = selectedTag ? getTagUsage(selectedTag.tagSlot) : null
const handleClose = (openState: boolean) => {
if (!openState) {
setIsCreatingTag(false)
setCreateTagForm({
displayName: '',
fieldType: 'text',
})
}
onOpenChange(openState)
}
return (
<>
<Modal open={open} onOpenChange={handleClose}>
<ModalContent>
<ModalHeader>
<div className='flex items-center justify-between'>
<span>Tags</span>
</div>
</ModalHeader>
<ModalBody className='!pb-[16px]'>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[8px]'>
<Label>
Tags:{' '}
<span className='pl-[6px] text-[var(--text-tertiary)]'>
{kbTagDefinitions.length}/{MAX_TAG_SLOTS} slots used
</span>
</Label>
{kbTagDefinitions.length === 0 && !isCreatingTag && (
<div className='rounded-[6px] border p-[16px] text-center'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
No tag definitions yet. Create your first tag to organize documents.
</p>
</div>
)}
{kbTagDefinitions.map((tag) => {
const usage = getTagUsage(tag.tagSlot)
return (
<div
key={tag.id}
className='flex cursor-pointer items-center gap-2 rounded-[4px] border p-[8px] hover:bg-[var(--surface-2)]'
onClick={() => handleViewDocuments(tag)}
>
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
{tag.displayName}
</span>
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
<span className='min-w-0 flex-1 text-[11px] text-[var(--text-muted)]'>
{usage.documentCount} document{usage.documentCount !== 1 ? 's' : ''}
</span>
<div className='flex flex-shrink-0 items-center gap-1'>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleDeleteTagClick(tag)
}}
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
>
<Trash className='h-3 w-3' />
</Button>
</div>
</div>
)
})}
{!isCreatingTag && (
<Button
variant='default'
onClick={openTagCreator}
disabled={kbTagDefinitions.length >= MAX_TAG_SLOTS}
className='w-full'
>
Add Tag
</Button>
)}
{isCreatingTag && (
<div className='space-y-[8px] rounded-[6px] border p-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='tagName'>Tag Name</Label>
<Input
id='tagName'
value={createTagForm.displayName}
onChange={(e) =>
setCreateTagForm({ ...createTagForm, displayName: e.target.value })
}
placeholder='Enter tag name'
className={cn(tagNameConflict && 'border-[var(--text-error)]')}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag()) {
e.preventDefault()
saveTagDefinition()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelCreatingTag()
}
}}
/>
{tagNameConflict && (
<span className='text-[11px] text-[var(--text-error)]'>
A tag with this name already exists
</span>
)}
</div>
{/* Type selector commented out - only "text" type is currently supported
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='tagType'>Type</Label>
<Input id='tagType' value='Text' disabled className='capitalize' />
</div>
*/}
<div className='flex gap-[8px]'>
<Button variant='default' onClick={cancelCreatingTag} className='flex-1'>
Cancel
</Button>
<Button
variant='primary'
onClick={saveTagDefinition}
className='flex-1'
disabled={!canSaveTag() || isSavingTag}
>
{isSavingTag ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Creating...
</>
) : (
'Create Tag'
)}
</Button>
</div>
</div>
)}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => handleClose(false)}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Delete Tag Confirmation Dialog */}
<Modal open={deleteTagDialogOpen} onOpenChange={setDeleteTagDialogOpen}>
<ModalContent size='sm'>
<ModalHeader>Delete Tag</ModalHeader>
<ModalBody className='!pb-[16px]'>
<div className='space-y-[8px]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
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' : ''}.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
{selectedTagUsage && selectedTagUsage.documentCount > 0 && (
<div className='flex flex-col gap-[8px]'>
<Label>Affected documents:</Label>
<DocumentList
documents={selectedTagUsage.documents}
totalCount={selectedTagUsage.documentCount}
/>
</div>
)}
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
disabled={isDeletingTag}
onClick={() => setDeleteTagDialogOpen(false)}
>
Cancel
</Button>
<Button
variant='primary'
onClick={confirmDeleteTag}
disabled={isDeletingTag}
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
>
{isDeletingTag ? <>Deleting...</> : 'Delete Tag'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* View Documents Dialog */}
<Modal open={viewDocumentsDialogOpen} onOpenChange={setViewDocumentsDialogOpen}>
<ModalContent size='sm'>
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
<ModalBody className='!pb-[16px]'>
<div className='space-y-[8px]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{selectedTagUsage?.documentCount || 0} document
{selectedTagUsage?.documentCount !== 1 ? 's are' : ' is'} currently using this tag
definition.
</p>
{selectedTagUsage?.documentCount === 0 ? (
<div className='rounded-[6px] border p-[16px] text-center'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
This tag definition is not being used by any documents. You can safely delete it
to free up the tag slot.
</p>
</div>
) : (
<DocumentList
documents={selectedTagUsage?.documents || []}
totalCount={selectedTagUsage?.documentCount || 0}
/>
)}
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setViewDocumentsDialogOpen(false)}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -1,3 +1,3 @@
export { ActionBar } from './action-bar/action-bar'
export { KnowledgeBaseLoading } from './knowledge-base-loading/knowledge-base-loading'
export { UploadModal } from './upload-modal/upload-modal'
export { AddDocumentsModal } from './add-documents-modal/add-documents-modal'
export { BaseTagsModal } from './base-tags-modal/base-tags-modal'

View File

@@ -1,72 +0,0 @@
'use client'
import { Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/emcn'
import {
DocumentTableSkeleton,
KnowledgeHeader,
} from '@/app/workspace/[workspaceId]/knowledge/components'
interface KnowledgeBaseLoadingProps {
knowledgeBaseName: string
}
export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps) {
const params = useParams()
const workspaceId = params?.workspaceId as string
const breadcrumbs = [
{
id: 'knowledge-root',
label: 'Knowledge',
href: `/workspace/${workspaceId}/knowledge`,
},
{
id: 'knowledge-base-loading',
label: knowledgeBaseName,
},
]
return (
<div className='flex h-[100vh] flex-col pl-64'>
{/* Fixed Header with Breadcrumbs */}
<KnowledgeHeader breadcrumbs={breadcrumbs} />
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Main Content */}
<div className='flex-1 overflow-auto'>
<div className='px-6 pb-6'>
{/* Search and Create Section */}
<div className='mb-4 flex items-center justify-between pt-1'>
<div className='relative max-w-md flex-1'>
<div className='relative flex items-center'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
<input
type='text'
placeholder='Search documents...'
disabled
className='h-10 w-full rounded-md border bg-background px-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
/>
</div>
</div>
<div className='flex items-center gap-2'>
{/* Add Documents Button - disabled state */}
<Button disabled variant='primary' className='flex items-center gap-1'>
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
<span>Add Documents</span>
</Button>
</div>
</div>
{/* Table container */}
<DocumentTableSkeleton isSidebarCollapsed={false} rowCount={8} />
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,315 +0,0 @@
'use client'
import { useRef, useState } from 'react'
import { AlertCircle, Check, Loader2, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { createLogger } from '@/lib/logs/console/logger'
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
const logger = createLogger('UploadModal')
interface FileWithPreview extends File {
preview: string
}
interface UploadModalProps {
open: boolean
onOpenChange: (open: boolean) => void
knowledgeBaseId: string
chunkingConfig?: {
maxSize: number
minSize: number
overlap: number
}
onUploadComplete?: () => void
}
export function UploadModal({
open,
onOpenChange,
knowledgeBaseId,
chunkingConfig,
onUploadComplete,
}: UploadModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const fileInputRef = useRef<HTMLInputElement>(null)
const [files, setFiles] = useState<FileWithPreview[]>([])
const [fileError, setFileError] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const { isUploading, uploadProgress, uploadError, uploadFiles, clearError } = useKnowledgeUpload({
workspaceId,
onUploadComplete: () => {
logger.info(`Successfully uploaded ${files.length} files`)
onUploadComplete?.()
handleClose()
},
})
const handleClose = () => {
if (isUploading) return // Prevent closing during upload
setFiles([])
setFileError(null)
clearError()
setIsDragging(false)
onOpenChange(false)
}
const validateFile = (file: File): string | null => {
return validateKnowledgeBaseFile(file)
}
const processFiles = (fileList: FileList | File[]) => {
setFileError(null)
const newFiles: FileWithPreview[] = []
for (const file of Array.from(fileList)) {
const error = validateFile(file)
if (error) {
setFileError(error)
return
}
const fileWithPreview = Object.assign(file, {
preview: URL.createObjectURL(file),
})
newFiles.push(fileWithPreview)
}
setFiles((prev) => [...prev, ...newFiles])
}
const removeFile = (index: number) => {
setFiles((prev) => {
const newFiles = [...prev]
const removedFile = newFiles.splice(index, 1)[0]
if (removedFile.preview) {
URL.revokeObjectURL(removedFile.preview)
}
return newFiles
})
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
processFiles(e.target.files)
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (e.dataTransfer.files) {
processFiles(e.dataTransfer.files)
}
}
const handleUpload = async () => {
if (files.length === 0) return
try {
await uploadFiles(files, knowledgeBaseId, {
chunkSize: chunkingConfig?.maxSize || 1024,
minCharactersPerChunk: chunkingConfig?.minSize || 1,
chunkOverlap: chunkingConfig?.overlap || 200,
recipe: 'default',
})
} catch (error) {
logger.error('Error uploading files:', error)
}
}
const getFileIcon = (mimeType: string, filename: string) => {
const IconComponent = getDocumentIcon(mimeType, filename)
return <IconComponent className='h-10 w-8' />
}
return (
<Modal open={open} onOpenChange={handleClose}>
<ModalContent className='max-h-[95vh] sm:max-w-[600px]'>
<ModalHeader>Upload Documents</ModalHeader>
<ModalBody>
<div className='space-y-[12px]'>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Select Files
</Label>
{files.length === 0 ? (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`relative flex cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-dashed p-8 text-center transition-colors ${
isDragging
? 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)]/5'
: 'border-[var(--c-575757)] hover:border-[var(--text-secondary)]'
}`}
>
<input
ref={fileInputRef}
type='file'
accept={ACCEPT_ATTRIBUTE}
onChange={handleFileChange}
className='hidden'
multiple
/>
<div className='space-y-2'>
<p className='font-medium text-[var(--text-primary)] text-sm'>
{isDragging ? 'Drop files here!' : 'Drop files here or click to browse'}
</p>
<p className='text-[var(--text-tertiary)] text-xs'>
Supports PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON, YAML,
YML (max 100MB each)
</p>
</div>
</div>
) : (
<div className='space-y-2'>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`cursor-pointer rounded-md border border-dashed p-3 text-center transition-colors ${
isDragging
? 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)]/5'
: 'border-[var(--c-575757)] hover:border-[var(--text-secondary)]'
}`}
>
<input
ref={fileInputRef}
type='file'
accept={ACCEPT_ATTRIBUTE}
onChange={handleFileChange}
className='hidden'
multiple
/>
<p className='text-[var(--text-primary)] text-sm'>
{isDragging ? 'Drop more files here!' : 'Drop more files or click to browse'}
</p>
</div>
<div className='max-h-80 space-y-2 overflow-auto'>
{files.map((file, index) => {
const fileStatus = uploadProgress.fileStatuses?.[index]
const isCurrentlyUploading = fileStatus?.status === 'uploading'
const isCompleted = fileStatus?.status === 'completed'
const isFailed = fileStatus?.status === 'failed'
return (
<div key={index} className='rounded-md border p-3'>
<div className='flex items-center gap-3'>
{getFileIcon(file.type, file.name)}
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
{isCurrentlyUploading && (
<Loader2 className='h-4 w-4 animate-spin text-[var(--brand-primary-hex)]' />
)}
{isCompleted && (
<Check className='h-4 w-4 text-[var(--text-success)]' />
)}
{isFailed && <X className='h-4 w-4 text-[var(--text-error)]' />}
<p className='truncate font-medium text-[var(--text-primary)] text-sm'>
{file.name}
</p>
</div>
<div className='flex items-center gap-2'>
<p className='text-[var(--text-tertiary)] text-xs'>
{formatFileSize(file.size)}
</p>
{isCurrentlyUploading && (
<div className='min-w-0 max-w-32 flex-1'>
<Progress value={fileStatus?.progress || 0} className='h-1' />
</div>
)}
</div>
{isFailed && fileStatus?.error && (
<p className='mt-1 text-[var(--text-error)] text-xs'>
{fileStatus.error}
</p>
)}
</div>
<Button
type='button'
variant='ghost'
onClick={() => removeFile(index)}
disabled={isUploading}
className='h-8 w-8 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-error)]'
>
<X className='h-4 w-4' />
</Button>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Show upload error first, then file error only if no upload error */}
{uploadError && (
<div className='rounded-md border border-[var(--text-error)]/50 bg-[var(--text-error)]/10 px-3 py-2'>
<div className='flex items-start gap-2'>
<AlertCircle className='mt-0.5 h-4 w-4 shrink-0 text-[var(--text-error)]' />
<div className='flex-1 text-[var(--text-error)] text-sm'>
{uploadError.message}
</div>
</div>
</div>
)}
{fileError && !uploadError && (
<div className='rounded-md border border-[var(--text-error)]/50 bg-[var(--text-error)]/10 px-3 py-2 text-[var(--text-error)] text-sm'>
{fileError}
</div>
)}
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleClose} disabled={isUploading}>
Cancel
</Button>
<Button
variant='primary'
onClick={handleUpload}
disabled={files.length === 0 || isUploading}
>
{isUploading
? uploadProgress.stage === 'uploading'
? `Uploading ${uploadProgress.filesCompleted + 1}/${uploadProgress.totalFiles}...`
: uploadProgress.stage === 'processing'
? 'Processing...'
: 'Uploading...'
: `Upload ${files.length} file${files.length !== 1 ? 's' : ''}`}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,160 @@
'use client'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { Badge, DocumentAttachment, Tooltip } from '@/components/emcn'
interface BaseCardProps {
id?: string
title: string
docCount: number
description: string
createdAt?: string
updatedAt?: string
}
/**
* Formats a date string to relative time (e.g., "2h ago", "3d ago")
*/
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return 'just now'
}
if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60)
return `${minutes}m ago`
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600)
return `${hours}h ago`
}
if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400)
return `${days}d ago`
}
if (diffInSeconds < 2592000) {
const weeks = Math.floor(diffInSeconds / 604800)
return `${weeks}w ago`
}
if (diffInSeconds < 31536000) {
const months = Math.floor(diffInSeconds / 2592000)
return `${months}mo ago`
}
const years = Math.floor(diffInSeconds / 31536000)
return `${years}y ago`
}
/**
* Formats a date string to absolute format for tooltip display
*/
function formatAbsoluteDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
/**
* Skeleton placeholder for a knowledge base card
*/
export function BaseCardSkeleton() {
return (
<div className='group flex h-full cursor-pointer flex-col gap-[12px] rounded-[4px] bg-[var(--surface-elevated)] px-[8px] py-[6px] transition-colors hover:bg-[var(--surface-5)]'>
<div className='flex items-center justify-between gap-[8px]'>
<div className='h-[17px] w-[120px] animate-pulse rounded-[4px] bg-[var(--surface-9)]' />
<div className='h-[22px] w-[90px] animate-pulse rounded-[4px] bg-[var(--surface-5)]' />
</div>
<div className='flex flex-1 flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[6px]'>
<div className='h-[12px] w-[12px] animate-pulse rounded-[2px] bg-[var(--surface-9)]' />
<div className='h-[15px] w-[45px] animate-pulse rounded-[4px] bg-[var(--surface-9)]' />
</div>
<div className='h-[15px] w-[120px] animate-pulse rounded-[4px] bg-[var(--surface-5)]' />
</div>
<div className='h-0 w-full border-[var(--divider)] border-t' />
<div className='flex h-[36px] flex-col gap-[6px]'>
<div className='h-[15px] w-full animate-pulse rounded-[4px] bg-[var(--surface-5)]' />
<div className='h-[15px] w-[75%] animate-pulse rounded-[4px] bg-[var(--surface-5)]' />
</div>
</div>
</div>
)
}
/**
* Renders multiple knowledge base card skeletons as a fragment
*/
export function BaseCardSkeletonGrid({ count = 8 }: { count?: number }) {
return (
<>
{Array.from({ length: count }).map((_, i) => (
<BaseCardSkeleton key={i} />
))}
</>
)
}
/**
* Knowledge base card component displaying overview information
*/
export function BaseCard({ id, title, docCount, description, updatedAt }: BaseCardProps) {
const params = useParams()
const workspaceId = params?.workspaceId as string
const searchParams = new URLSearchParams({
kbName: title,
})
const href = `/workspace/${workspaceId}/knowledge/${id || title.toLowerCase().replace(/\s+/g, '-')}?${searchParams.toString()}`
const shortId = id ? `kb-${id.slice(0, 8)}` : ''
return (
<Link href={href} prefetch={true} className='h-full'>
<div className='group flex h-full cursor-pointer flex-col gap-[12px] rounded-[4px] bg-[var(--surface-elevated)] px-[8px] py-[6px] transition-colors hover:bg-[var(--surface-5)]'>
<div className='flex items-center justify-between gap-[8px]'>
<h3 className='min-w-0 flex-1 truncate text-[14px] text-[var(--text-primary)]'>
{title}
</h3>
{shortId && <Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>}
</div>
<div className='flex flex-1 flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<span className='flex items-center gap-[6px] text-[12px] text-[var(--text-tertiary)]'>
<DocumentAttachment className='h-[12px] w-[12px]' />
{docCount} {docCount === 1 ? 'doc' : 'docs'}
</span>
{updatedAt && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='text-[12px] text-[var(--text-muted)]'>
last updated: {formatRelativeTime(updatedAt)}
</span>
</Tooltip.Trigger>
<Tooltip.Content>{formatAbsoluteDate(updatedAt)}</Tooltip.Content>
</Tooltip.Root>
)}
</div>
<div className='h-0 w-full border-[var(--divider)] border-t' />
<p className='line-clamp-2 h-[36px] text-[12px] text-[var(--text-tertiary)] leading-[18px]'>
{description}
</p>
</div>
</div>
</Link>
)
}

View File

@@ -1,144 +0,0 @@
'use client'
import { useState } from 'react'
import { Check, Copy, LibraryBig } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('BaseOverviewComponent')
interface BaseOverviewProps {
id?: string
title: string
docCount: number
description: string
createdAt?: string
updatedAt?: string
}
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return 'just now'
}
if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60)
return `${minutes}m ago`
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600)
return `${hours}h ago`
}
if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400)
return `${days}d ago`
}
if (diffInSeconds < 2592000) {
const weeks = Math.floor(diffInSeconds / 604800)
return `${weeks}w ago`
}
if (diffInSeconds < 31536000) {
const months = Math.floor(diffInSeconds / 2592000)
return `${months}mo ago`
}
const years = Math.floor(diffInSeconds / 31536000)
return `${years}y ago`
}
function formatAbsoluteDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function BaseOverview({
id,
title,
docCount,
description,
createdAt,
updatedAt,
}: BaseOverviewProps) {
const [isCopied, setIsCopied] = useState(false)
const params = useParams()
const workspaceId = params?.workspaceId as string
const searchParams = new URLSearchParams({
kbName: title,
})
const href = `/workspace/${workspaceId}/knowledge/${id || title.toLowerCase().replace(/\s+/g, '-')}?${searchParams.toString()}`
const handleCopy = async (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (id) {
try {
await navigator.clipboard.writeText(id)
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000)
} catch (err) {
logger.error('Failed to copy ID:', err)
}
}
}
return (
<Link href={href} prefetch={true}>
<div className='group flex cursor-pointer flex-col gap-3 rounded-md border bg-background p-4 transition-colors hover:bg-accent/50'>
<div className='flex items-center gap-2'>
<LibraryBig className='h-4 w-4 flex-shrink-0 text-muted-foreground' />
<h3 className='truncate font-medium text-sm leading-tight'>{title}</h3>
</div>
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2 text-muted-foreground text-xs'>
<span>
{docCount} {docCount === 1 ? 'doc' : 'docs'}
</span>
<span></span>
<div className='flex items-center gap-2'>
<span className='truncate font-mono'>{id?.slice(0, 8)}</span>
<button
onClick={handleCopy}
className='flex h-4 w-4 items-center justify-center rounded text-gray-500 hover:bg-gray-100 hover:text-gray-700'
>
{isCopied ? <Check className='h-3 w-3' /> : <Copy className='h-3 w-3' />}
</button>
</div>
</div>
{/* Timestamps */}
{(createdAt || updatedAt) && (
<div className='flex items-center gap-2 text-muted-foreground text-xs'>
{updatedAt && (
<span title={`Last updated: ${formatAbsoluteDate(updatedAt)}`}>
Updated {formatRelativeTime(updatedAt)}
</span>
)}
{updatedAt && createdAt && <span></span>}
{createdAt && (
<span title={`Created: ${formatAbsoluteDate(createdAt)}`}>
Created {formatRelativeTime(createdAt)}
</span>
)}
</div>
)}
<p className='line-clamp-2 overflow-hidden text-muted-foreground text-xs'>
{description}
</p>
</div>
</div>
</Link>
)
}

View File

@@ -0,0 +1,536 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { AlertCircle, Loader2, RotateCcw, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
Button,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
const logger = createLogger('CreateBaseModal')
interface FileWithPreview extends File {
preview: string
}
interface CreateBaseModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onKnowledgeBaseCreated?: (knowledgeBase: KnowledgeBaseData) => void
}
const FormSchema = z
.object({
name: z
.string()
.min(1, 'Name is required')
.max(100, 'Name must be less than 100 characters')
.refine((value) => value.trim().length > 0, 'Name cannot be empty'),
description: z.string().max(500, 'Description must be less than 500 characters').optional(),
minChunkSize: z
.number()
.min(1, 'Min chunk size must be at least 1')
.max(2000, 'Min chunk size must be less than 2000'),
maxChunkSize: z
.number()
.min(100, 'Max chunk size must be at least 100')
.max(4000, 'Max chunk size must be less than 4000'),
overlapSize: z
.number()
.min(0, 'Overlap size must be non-negative')
.max(500, 'Overlap size must be less than 500'),
})
.refine((data) => data.minChunkSize < data.maxChunkSize, {
message: 'Min chunk size must be less than max chunk size',
path: ['minChunkSize'],
})
type FormValues = z.infer<typeof FormSchema>
interface SubmitStatus {
type: 'success' | 'error'
message: string
}
export function CreateBaseModal({
open,
onOpenChange,
onKnowledgeBaseCreated,
}: CreateBaseModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const fileInputRef = useRef<HTMLInputElement>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitStatus, setSubmitStatus] = useState<SubmitStatus | null>(null)
const [files, setFiles] = useState<FileWithPreview[]>([])
const [fileError, setFileError] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [dragCounter, setDragCounter] = useState(0)
const [retryingIndexes, setRetryingIndexes] = useState<Set<number>>(new Set())
const scrollContainerRef = useRef<HTMLDivElement>(null)
const { uploadFiles, isUploading, uploadProgress, clearError } = useKnowledgeUpload({
workspaceId,
onUploadComplete: (uploadedFiles) => {
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
},
})
const handleClose = (open: boolean) => {
if (!open) {
clearError()
}
onOpenChange(open)
}
useEffect(() => {
return () => {
files.forEach((file) => {
if (file.preview) {
URL.revokeObjectURL(file.preview)
}
})
}
}, [files])
const {
register,
handleSubmit,
reset,
watch,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: '',
description: '',
minChunkSize: 1,
maxChunkSize: 1024,
overlapSize: 200,
},
mode: 'onSubmit',
})
const nameValue = watch('name')
useEffect(() => {
if (open) {
setSubmitStatus(null)
setFileError(null)
setFiles([])
setIsDragging(false)
setDragCounter(0)
setRetryingIndexes(new Set())
reset({
name: '',
description: '',
minChunkSize: 1,
maxChunkSize: 1024,
overlapSize: 200,
})
}
}, [open, reset])
const processFiles = async (fileList: FileList | File[]) => {
setFileError(null)
if (!fileList || fileList.length === 0) return
try {
const newFiles: FileWithPreview[] = []
let hasError = false
for (const file of Array.from(fileList)) {
const validationError = validateKnowledgeBaseFile(file)
if (validationError) {
setFileError(validationError)
hasError = true
continue
}
const fileWithPreview = Object.assign(file, {
preview: URL.createObjectURL(file),
}) as FileWithPreview
newFiles.push(fileWithPreview)
}
if (!hasError && newFiles.length > 0) {
setFiles((prev) => [...prev, ...newFiles])
}
} catch (error) {
logger.error('Error processing files:', error)
setFileError('An error occurred while processing files. Please try again.')
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
await processFiles(e.target.files)
}
}
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => {
const newCount = prev + 1
if (newCount === 1) {
setIsDragging(true)
}
return newCount
})
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => {
const newCount = prev - 1
if (newCount === 0) {
setIsDragging(false)
}
return newCount
})
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
setDragCounter(0)
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
await processFiles(e.dataTransfer.files)
}
}
const removeFile = (index: number) => {
setFiles((prev) => {
URL.revokeObjectURL(prev[index].preview)
return prev.filter((_, i) => i !== index)
})
}
const onSubmit = async (data: FormValues) => {
setIsSubmitting(true)
setSubmitStatus(null)
try {
const knowledgeBasePayload = {
name: data.name,
description: data.description || undefined,
workspaceId: workspaceId,
chunkingConfig: {
maxSize: data.maxChunkSize,
minSize: data.minChunkSize,
overlap: data.overlapSize,
},
}
const response = await fetch('/api/knowledge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(knowledgeBasePayload),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to create knowledge base')
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to create knowledge base')
}
const newKnowledgeBase = result.data
if (files.length > 0) {
newKnowledgeBase.docCount = files.length
if (onKnowledgeBaseCreated) {
onKnowledgeBaseCreated(newKnowledgeBase)
}
const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
chunkSize: data.maxChunkSize,
minCharactersPerChunk: data.minChunkSize,
chunkOverlap: data.overlapSize,
recipe: 'default',
})
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
} else {
if (onKnowledgeBaseCreated) {
onKnowledgeBaseCreated(newKnowledgeBase)
}
}
files.forEach((file) => URL.revokeObjectURL(file.preview))
setFiles([])
handleClose(false)
} catch (error) {
logger.error('Error creating knowledge base:', error)
setSubmitStatus({
type: 'error',
message: error instanceof Error ? error.message : 'An unknown error occurred',
})
} finally {
setIsSubmitting(false)
}
}
return (
<Modal open={open} onOpenChange={handleClose}>
<ModalContent>
<ModalHeader>Create Knowledge Base</ModalHeader>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
<ModalBody className='!pb-[16px]'>
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[12px]'>
{submitStatus && submitStatus.type === 'error' && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{submitStatus.message}</AlertDescription>
</Alert>
)}
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='name'>Name</Label>
<Input
id='name'
placeholder='Enter knowledge base name'
{...register('name')}
className={cn(errors.name && 'border-[var(--text-error)]')}
/>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='description'>Description</Label>
<Textarea
id='description'
placeholder='Describe this knowledge base (optional)'
rows={3}
{...register('description')}
className={cn(errors.description && 'border-[var(--text-error)]')}
/>
</div>
<div className='space-y-[12px] rounded-[6px] bg-[var(--surface-6)] px-[12px] py-[14px]'>
<div className='grid grid-cols-2 gap-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='minChunkSize'>Min Chunk Size</Label>
<Input
id='minChunkSize'
placeholder='1'
{...register('minChunkSize', { valueAsNumber: true })}
className={cn(errors.minChunkSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='min-chunk-size'
/>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='maxChunkSize'>Max Chunk Size</Label>
<Input
id='maxChunkSize'
placeholder='1024'
{...register('maxChunkSize', { valueAsNumber: true })}
className={cn(errors.maxChunkSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='max-chunk-size'
/>
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='overlapSize'>Overlap Size</Label>
<Input
id='overlapSize'
placeholder='200'
{...register('overlapSize', { valueAsNumber: true })}
className={cn(errors.overlapSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='overlap-size'
/>
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<Label>Upload Documents</Label>
<Button
type='button'
variant='default'
onClick={() => fileInputRef.current?.click()}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--c-575757)] border-dashed py-[10px]',
isDragging && 'border-[var(--brand-primary-hex)]'
)}
>
<input
ref={fileInputRef}
type='file'
accept={ACCEPT_ATTRIBUTE}
onChange={handleFileChange}
className='hidden'
multiple
/>
<div className='flex flex-col gap-[2px] text-center'>
<span className='text-[var(--text-primary)]'>
{isDragging ? 'Drop files here' : 'Drop files here or click to browse'}
</span>
<span className='text-[11px] text-[var(--text-tertiary)]'>
PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML (max 100MB each)
</span>
</div>
</Button>
</div>
{files.length > 0 && (
<div className='space-y-2'>
<Label>Selected Files</Label>
<div className='space-y-2'>
{files.map((file, index) => {
const fileStatus = uploadProgress.fileStatuses?.[index]
const isFailed = fileStatus?.status === 'failed'
const isRetrying = retryingIndexes.has(index)
const isProcessing = fileStatus?.status === 'uploading' || isRetrying
return (
<div
key={index}
className='flex items-center gap-2 rounded-[4px] border p-[8px]'
>
{isFailed && !isRetrying && (
<AlertCircle className='h-4 w-4 flex-shrink-0 text-[var(--text-error)]' />
)}
<span
className={cn(
'min-w-0 flex-1 truncate text-[12px]',
isFailed && !isRetrying && 'text-[var(--text-error)]'
)}
title={file.name}
>
{file.name}
</span>
<span className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
{formatFileSize(file.size)}
</span>
<div className='flex flex-shrink-0 items-center gap-1'>
{isFailed && !isRetrying && (
<Button
type='button'
variant='ghost'
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
onClick={() => {
setRetryingIndexes((prev) => new Set(prev).add(index))
removeFile(index)
}}
disabled={isUploading}
>
<RotateCcw className='h-3.5 w-3.5' />
</Button>
)}
{isProcessing ? (
<Loader2 className='h-4 w-4 animate-spin text-[var(--text-muted)]' />
) : (
<Button
type='button'
variant='ghost'
className='h-4 w-4 p-0'
onClick={() => removeFile(index)}
disabled={isUploading}
>
<X className='h-3.5 w-3.5' />
</Button>
)}
</div>
</div>
)
})}
</div>
</div>
)}
{fileError && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{fileError}</AlertDescription>
</Alert>
)}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => handleClose(false)}
type='button'
disabled={isSubmitting}
>
Cancel
</Button>
<Button variant='primary' type='submit' disabled={isSubmitting || !nameValue?.trim()}>
{isSubmitting
? isUploading
? uploadProgress.stage === 'uploading'
? `Uploading ${uploadProgress.filesCompleted}/${uploadProgress.totalFiles}...`
: uploadProgress.stage === 'processing'
? 'Processing...'
: 'Creating...'
: 'Creating...'
: 'Create'}
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
)
}

View File

@@ -1,650 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { AlertCircle, Check, Loader2, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button, Input, Label, Textarea } from '@/components/emcn'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn/components/modal/modal'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Progress } from '@/components/ui/progress'
import { createLogger } from '@/lib/logs/console/logger'
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
const logger = createLogger('CreateModal')
interface FileWithPreview extends File {
preview: string
}
interface CreateModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onKnowledgeBaseCreated?: (knowledgeBase: KnowledgeBaseData) => void
}
const FormSchema = z
.object({
name: z
.string()
.min(1, 'Name is required')
.max(100, 'Name must be less than 100 characters')
.refine((value) => value.trim().length > 0, 'Name cannot be empty'),
description: z.string().max(500, 'Description must be less than 500 characters').optional(),
minChunkSize: z
.number()
.min(1, 'Min chunk size must be at least 1')
.max(2000, 'Min chunk size must be less than 2000'),
maxChunkSize: z
.number()
.min(100, 'Max chunk size must be at least 100')
.max(4000, 'Max chunk size must be less than 4000'),
overlapSize: z
.number()
.min(0, 'Overlap size must be non-negative')
.max(500, 'Overlap size must be less than 500'),
})
.refine((data) => data.minChunkSize < data.maxChunkSize, {
message: 'Min chunk size must be less than max chunk size',
path: ['minChunkSize'],
})
type FormValues = z.infer<typeof FormSchema>
interface SubmitStatus {
type: 'success' | 'error'
message: string
}
export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: CreateModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const fileInputRef = useRef<HTMLInputElement>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitStatus, setSubmitStatus] = useState<SubmitStatus | null>(null)
const [files, setFiles] = useState<FileWithPreview[]>([])
const [fileError, setFileError] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [dragCounter, setDragCounter] = useState(0) // Track drag events to handle nested elements
const scrollContainerRef = useRef<HTMLDivElement>(null)
const dropZoneRef = useRef<HTMLDivElement>(null)
const { uploadFiles, isUploading, uploadProgress, uploadError, clearError } = useKnowledgeUpload({
workspaceId,
onUploadComplete: (uploadedFiles) => {
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
},
})
const handleClose = (open: boolean) => {
if (!open) {
clearError()
}
onOpenChange(open)
}
useEffect(() => {
return () => {
files.forEach((file) => {
if (file.preview) {
URL.revokeObjectURL(file.preview)
}
})
}
}, [files])
const {
register,
handleSubmit,
reset,
watch,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: '',
description: '',
minChunkSize: 1,
maxChunkSize: 1024,
overlapSize: 200,
},
mode: 'onSubmit',
})
const nameValue = watch('name')
useEffect(() => {
if (open) {
setSubmitStatus(null)
setFileError(null)
setFiles([])
setIsDragging(false)
setDragCounter(0)
reset({
name: '',
description: '',
minChunkSize: 1,
maxChunkSize: 1024,
overlapSize: 200,
})
}
}, [open, reset])
const processFiles = async (fileList: FileList | File[]) => {
setFileError(null)
if (!fileList || fileList.length === 0) return
try {
const newFiles: FileWithPreview[] = []
let hasError = false
for (const file of Array.from(fileList)) {
const validationError = validateKnowledgeBaseFile(file)
if (validationError) {
setFileError(validationError)
hasError = true
continue
}
const fileWithPreview = Object.assign(file, {
preview: URL.createObjectURL(file),
}) as FileWithPreview
newFiles.push(fileWithPreview)
}
if (!hasError && newFiles.length > 0) {
setFiles((prev) => [...prev, ...newFiles])
}
} catch (error) {
logger.error('Error processing files:', error)
setFileError('An error occurred while processing files. Please try again.')
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
await processFiles(e.target.files)
}
}
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => {
const newCount = prev + 1
if (newCount === 1) {
setIsDragging(true)
}
return newCount
})
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => {
const newCount = prev - 1
if (newCount === 0) {
setIsDragging(false)
}
return newCount
})
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
setDragCounter(0)
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
await processFiles(e.dataTransfer.files)
}
}
const removeFile = (index: number) => {
setFiles((prev) => {
URL.revokeObjectURL(prev[index].preview)
return prev.filter((_, i) => i !== index)
})
}
const getFileIcon = (mimeType: string, filename: string) => {
const IconComponent = getDocumentIcon(mimeType, filename)
return <IconComponent className='h-10 w-8' />
}
const onSubmit = async (data: FormValues) => {
setIsSubmitting(true)
setSubmitStatus(null)
try {
const knowledgeBasePayload = {
name: data.name,
description: data.description || undefined,
workspaceId: workspaceId,
chunkingConfig: {
maxSize: data.maxChunkSize,
minSize: data.minChunkSize,
overlap: data.overlapSize,
},
}
const response = await fetch('/api/knowledge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(knowledgeBasePayload),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to create knowledge base')
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to create knowledge base')
}
const newKnowledgeBase = result.data
if (files.length > 0) {
newKnowledgeBase.docCount = files.length
if (onKnowledgeBaseCreated) {
onKnowledgeBaseCreated(newKnowledgeBase)
}
const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
chunkSize: data.maxChunkSize,
minCharactersPerChunk: data.minChunkSize,
chunkOverlap: data.overlapSize,
recipe: 'default',
})
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
} else {
if (onKnowledgeBaseCreated) {
onKnowledgeBaseCreated(newKnowledgeBase)
}
}
files.forEach((file) => URL.revokeObjectURL(file.preview))
setFiles([])
handleClose(false)
} catch (error) {
logger.error('Error creating knowledge base:', error)
setSubmitStatus({
type: 'error',
message: error instanceof Error ? error.message : 'An unknown error occurred',
})
} finally {
setIsSubmitting(false)
}
}
return (
<Modal open={open} onOpenChange={handleClose}>
<ModalContent className='h-[78vh] max-h-[95vh] sm:max-w-[750px]'>
<ModalHeader>Create Knowledge Base</ModalHeader>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
<ModalBody>
<div ref={scrollContainerRef} className='space-y-[12px]'>
{/* Show upload error first, then submit error only if no upload error */}
{uploadError && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Upload Error</AlertTitle>
<AlertDescription>{uploadError.message}</AlertDescription>
</Alert>
)}
{submitStatus && submitStatus.type === 'error' && !uploadError && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{submitStatus.message}</AlertDescription>
</Alert>
)}
{/* Form Fields Section */}
<div className='space-y-[8px]'>
<Label
htmlFor='name'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Name *
</Label>
<Input
id='name'
placeholder='Enter knowledge base name'
{...register('name')}
className={errors.name ? 'border-[var(--text-error)]' : ''}
/>
{errors.name && (
<p className='mt-1 text-[var(--text-error)] text-sm'>{errors.name.message}</p>
)}
</div>
<div className='space-y-[8px]'>
<Label
htmlFor='description'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Description
</Label>
<Textarea
id='description'
placeholder='Describe what this knowledge base contains (optional)'
rows={3}
{...register('description')}
className={errors.description ? 'border-[var(--text-error)]' : ''}
/>
{errors.description && (
<p className='mt-1 text-[var(--text-error)] text-sm'>
{errors.description.message}
</p>
)}
</div>
{/* Chunk Configuration Section */}
<div className='space-y-[12px] rounded-lg border p-5'>
<h3 className='font-medium text-[var(--text-primary)] text-sm'>
Chunking Configuration
</h3>
{/* Min and Max Chunk Size Row */}
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-[8px]'>
<Label
htmlFor='minChunkSize'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Min Chunk Size
</Label>
<Input
id='minChunkSize'
type='number'
placeholder='1'
{...register('minChunkSize', { valueAsNumber: true })}
className={errors.minChunkSize ? 'border-[var(--text-error)]' : ''}
autoComplete='off'
data-form-type='other'
name='min-chunk-size'
/>
{errors.minChunkSize && (
<p className='mt-1 text-[var(--text-error)] text-xs'>
{errors.minChunkSize.message}
</p>
)}
</div>
<div className='space-y-[8px]'>
<Label
htmlFor='maxChunkSize'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Max Chunk Size
</Label>
<Input
id='maxChunkSize'
type='number'
placeholder='1024'
{...register('maxChunkSize', { valueAsNumber: true })}
className={errors.maxChunkSize ? 'border-[var(--text-error)]' : ''}
autoComplete='off'
data-form-type='other'
name='max-chunk-size'
/>
{errors.maxChunkSize && (
<p className='mt-1 text-[var(--text-error)] text-xs'>
{errors.maxChunkSize.message}
</p>
)}
</div>
</div>
{/* Overlap Size */}
<div className='space-y-[8px]'>
<Label
htmlFor='overlapSize'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Overlap Size
</Label>
<Input
id='overlapSize'
type='number'
placeholder='200'
{...register('overlapSize', { valueAsNumber: true })}
className={errors.overlapSize ? 'border-[var(--text-error)]' : ''}
autoComplete='off'
data-form-type='other'
name='overlap-size'
/>
{errors.overlapSize && (
<p className='mt-1 text-[var(--text-error)] text-xs'>
{errors.overlapSize.message}
</p>
)}
</div>
<p className='text-[var(--text-tertiary)] text-xs'>
Configure how documents are split into chunks for processing. Smaller chunks
provide more precise retrieval but may lose context.
</p>
</div>
{/* File Upload Section */}
<div className='space-y-[12px]'>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Upload Documents
</Label>
{files.length === 0 ? (
<div
ref={dropZoneRef}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`relative flex cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-dashed py-8 text-center transition-all duration-200 ${
isDragging
? 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)]/5'
: 'border-[var(--c-575757)] hover:border-[var(--text-secondary)]'
}`}
>
<input
ref={fileInputRef}
type='file'
accept={ACCEPT_ATTRIBUTE}
onChange={handleFileChange}
className='hidden'
multiple
/>
<div className='flex flex-col items-center gap-3'>
<div className='space-y-1'>
<p
className={`font-medium text-[var(--text-primary)] text-sm transition-colors duration-200 ${
isDragging ? 'text-[var(--brand-primary-hex)]' : ''
}`}
>
{isDragging ? 'Drop files here!' : 'Drop files here or click to browse'}
</p>
<p className='text-[var(--text-tertiary)] text-xs'>
Supports PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON,
YAML, YML (max 100MB each)
</p>
</div>
</div>
</div>
) : (
<div className='space-y-2'>
{/* Compact drop area at top of file list */}
<div
ref={dropZoneRef}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`cursor-pointer rounded-md border border-dashed p-3 text-center transition-all duration-200 ${
isDragging
? 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)]/5'
: 'border-[var(--c-575757)] hover:border-[var(--text-secondary)]'
}`}
>
<input
ref={fileInputRef}
type='file'
accept={ACCEPT_ATTRIBUTE}
onChange={handleFileChange}
className='hidden'
multiple
/>
<div className='flex items-center justify-center gap-2'>
<div>
<p
className={`font-medium text-[var(--text-primary)] text-sm transition-colors duration-200 ${
isDragging ? 'text-[var(--brand-primary-hex)]' : ''
}`}
>
{isDragging
? 'Drop more files here!'
: 'Drop more files or click to browse'}
</p>
<p className='text-[var(--text-tertiary)] text-xs'>
PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML (max 100MB
each)
</p>
</div>
</div>
</div>
{/* File list */}
<div className='space-y-2'>
{files.map((file, index) => {
const fileStatus = uploadProgress.fileStatuses?.[index]
const isCurrentlyUploading = fileStatus?.status === 'uploading'
const isCompleted = fileStatus?.status === 'completed'
const isFailed = fileStatus?.status === 'failed'
return (
<div
key={index}
className='flex items-center gap-3 rounded-md border p-3'
>
{getFileIcon(file.type, file.name)}
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
{isCurrentlyUploading && (
<Loader2 className='h-4 w-4 animate-spin text-[var(--brand-primary-hex)]' />
)}
{isCompleted && (
<Check className='h-4 w-4 text-[var(--text-success)]' />
)}
{isFailed && <X className='h-4 w-4 text-[var(--text-error)]' />}
<p className='truncate font-medium text-[var(--text-primary)] text-sm'>
{file.name}
</p>
</div>
<div className='flex items-center gap-2'>
<p className='text-[var(--text-tertiary)] text-xs'>
{formatFileSize(file.size)}
</p>
{isCurrentlyUploading && (
<div className='min-w-0 max-w-32 flex-1'>
<Progress value={fileStatus?.progress || 0} className='h-1' />
</div>
)}
</div>
{isFailed && fileStatus?.error && (
<p className='mt-1 text-[var(--text-error)] text-xs'>
{fileStatus.error}
</p>
)}
</div>
<Button
type='button'
variant='ghost'
onClick={() => removeFile(index)}
disabled={isUploading}
className='h-8 w-8 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-error)]'
>
<X className='h-4 w-4' />
</Button>
</div>
)
})}
</div>
</div>
)}
{fileError && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{fileError}</AlertDescription>
</Alert>
)}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => handleClose(false)}
type='button'
disabled={isSubmitting}
>
Cancel
</Button>
<Button variant='primary' type='submit' disabled={isSubmitting || !nameValue?.trim()}>
{isSubmitting
? isUploading
? uploadProgress.stage === 'uploading'
? `Uploading ${uploadProgress.filesCompleted}/${uploadProgress.totalFiles}...`
: uploadProgress.stage === 'processing'
? 'Processing...'
: 'Creating...'
: 'Creating...'
: 'Create Knowledge Base'}
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
)
}

View File

@@ -1,480 +0,0 @@
'use client'
import { useState } from 'react'
import { ChevronDown, Info, Plus, X } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import {
Badge,
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import { MAX_TAG_SLOTS, type TagSlot } from '@/lib/knowledge/constants'
import { createLogger } from '@/lib/logs/console/logger'
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
import { useNextAvailableSlot } from '@/hooks/use-next-available-slot'
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/use-tag-definitions'
const logger = createLogger('DocumentTagEntry')
export interface DocumentTag {
slot: string
displayName: string
fieldType: string
value: string
}
interface DocumentTagEntryProps {
tags: DocumentTag[]
onTagsChange: (newTags: DocumentTag[]) => void
disabled?: boolean
knowledgeBaseId: string
documentId: string | null
onSave?: (tagsToSave: DocumentTag[]) => Promise<void>
}
export function DocumentTagEntry({
tags,
onTagsChange,
disabled = false,
knowledgeBaseId,
documentId,
onSave,
}: DocumentTagEntryProps) {
// Use different hooks based on whether we have a documentId
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const { getNextAvailableSlot: getServerNextSlot } = useNextAvailableSlot(knowledgeBaseId)
// Use the document-level hook since we have documentId
const { saveTagDefinitions } = documentTagHook
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
// Modal state for tag editing
const [editingTagIndex, setEditingTagIndex] = useState<number | null>(null)
const [modalOpen, setModalOpen] = useState(false)
const [editForm, setEditForm] = useState({
displayName: '',
fieldType: 'text',
value: '',
})
const handleRemoveTag = async (index: number) => {
const updatedTags = tags.filter((_, i) => i !== index)
onTagsChange(updatedTags)
// Persist the changes if onSave is provided
if (onSave) {
try {
await onSave(updatedTags)
} catch (error) {
// Handle error silently - the UI will show the optimistic update
// but the user can retry if needed
}
}
}
// Open modal to edit tag
const openTagModal = (index: number) => {
const tag = tags[index]
setEditingTagIndex(index)
setEditForm({
displayName: tag.displayName,
fieldType: tag.fieldType,
value: tag.value,
})
setModalOpen(true)
}
// Open modal to create new tag
const openNewTagModal = () => {
setEditingTagIndex(null)
setEditForm({
displayName: '',
fieldType: 'text',
value: '',
})
setModalOpen(true)
}
// Save tag from modal
const saveTagFromModal = async () => {
if (!editForm.displayName.trim() || !editForm.value.trim()) return
try {
let targetSlot: string
if (editingTagIndex !== null) {
// EDIT MODE: Editing existing tag - use existing slot
targetSlot = tags[editingTagIndex].slot
} else {
// CREATE MODE: Check if using existing definition or creating new one
const existingDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
)
if (existingDefinition) {
// Using existing definition - use its slot
targetSlot = existingDefinition.tagSlot
} else {
// Creating new definition - get next available slot from server
const serverSlot = await getServerNextSlot(editForm.fieldType)
if (!serverSlot) {
throw new Error(`No available slots for new tag of type '${editForm.fieldType}'`)
}
targetSlot = serverSlot
}
}
// Update the tags array
if (editingTagIndex !== null) {
// Editing existing tag
const updatedTags = [...tags]
updatedTags[editingTagIndex] = {
...updatedTags[editingTagIndex],
displayName: editForm.displayName,
fieldType: editForm.fieldType,
value: editForm.value,
}
onTagsChange(updatedTags)
} else {
// Creating new tag
const newTag: DocumentTag = {
slot: targetSlot,
displayName: editForm.displayName,
fieldType: editForm.fieldType,
value: editForm.value,
}
const newTags = [...tags, newTag]
onTagsChange(newTags)
}
// Handle tag definition creation/update based on edit mode
if (editingTagIndex !== null) {
// EDIT MODE: Always update existing definition, never create new slots
const currentTag = tags[editingTagIndex]
const currentDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === currentTag.displayName.toLowerCase()
)
if (currentDefinition) {
const updatedDefinition: TagDefinitionInput = {
displayName: editForm.displayName,
fieldType: currentDefinition.fieldType, // Keep existing field type (can't change in edit mode)
tagSlot: currentDefinition.tagSlot, // Keep existing slot
_originalDisplayName: currentTag.displayName, // Tell server which definition to update
}
if (saveTagDefinitions) {
await saveTagDefinitions([updatedDefinition])
} else {
throw new Error('Cannot save tag definitions without a document ID')
}
await refreshTagDefinitions()
// Update the document tag's display name
const updatedTags = [...tags]
updatedTags[editingTagIndex] = {
...currentTag,
displayName: editForm.displayName,
fieldType: currentDefinition.fieldType,
}
onTagsChange(updatedTags)
}
} else {
// CREATE MODE: Adding new tag
const existingDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
)
if (!existingDefinition) {
// Create new definition
const newDefinition: TagDefinitionInput = {
displayName: editForm.displayName,
fieldType: editForm.fieldType,
tagSlot: targetSlot as TagSlot,
}
if (saveTagDefinitions) {
await saveTagDefinitions([newDefinition])
} else {
throw new Error('Cannot save tag definitions without a document ID')
}
await refreshTagDefinitions()
}
// If existingDefinition exists, use it (no server update needed)
}
// Save the actual document tags if onSave is provided
if (onSave) {
const updatedTags =
editingTagIndex !== null
? tags.map((tag, index) =>
index === editingTagIndex
? {
...tag,
displayName: editForm.displayName,
fieldType: editForm.fieldType,
value: editForm.value,
}
: tag
)
: [
...tags,
{
slot: targetSlot,
displayName: editForm.displayName,
fieldType: editForm.fieldType,
value: editForm.value,
},
]
await onSave(updatedTags)
}
setModalOpen(false)
} catch (error) {
logger.error('Error saving tag:', error)
}
}
// Filter available tag definitions based on context
const availableDefinitions = kbTagDefinitions.filter((def) => {
if (editingTagIndex !== null) {
// When editing, exclude only other used tag names (not the current one being edited)
return !tags.some(
(tag, index) =>
index !== editingTagIndex &&
tag.displayName.toLowerCase() === def.displayName.toLowerCase()
)
}
// When creating new, exclude all already used tag names
return !tags.some((tag) => tag.displayName.toLowerCase() === def.displayName.toLowerCase())
})
return (
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<h3 className='font-medium text-sm'>Document Tags</h3>
</div>
{/* Tags as Badges */}
<div className='flex flex-wrap gap-2'>
{tags.map((tag, index) => (
<Badge
key={index}
variant='outline'
className='cursor-pointer gap-2 px-3 py-1.5 text-sm transition-colors hover:bg-accent'
onClick={() => openTagModal(index)}
>
<span className='font-medium'>{tag.displayName || 'Unnamed Tag'}</span>
{tag.value && (
<>
<span className='text-muted-foreground'>:</span>
<span className='text-muted-foreground'>{tag.value}</span>
</>
)}
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
handleRemoveTag(index)
}}
disabled={disabled}
className='ml-1 h-4 w-4 p-0 text-muted-foreground hover:text-red-600'
>
<X className='h-3 w-3' />
</Button>
</Badge>
))}
{/* Add Tag Button */}
<Button
variant='outline'
size='sm'
onClick={openNewTagModal}
disabled={disabled}
className='gap-1 border-dashed text-muted-foreground hover:text-foreground'
>
<Plus className='h-4 w-4' />
Add Tag
</Button>
</div>
{tags.length === 0 && (
<div className='rounded-md border border-dashed p-4 text-center'>
<p className='text-muted-foreground text-sm'>
No tags added yet. Click "Add Tag" to get started.
</p>
</div>
)}
<div className='text-muted-foreground text-xs'>
{kbTagDefinitions.length} of {MAX_TAG_SLOTS} tag slots used
</div>
{/* Tag Edit Modal */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{editingTagIndex !== null ? 'Edit Tag' : 'Add New Tag'}</DialogTitle>
</DialogHeader>
<div className='space-y-4'>
{/* Tag Name */}
<div className='space-y-2'>
<div className='flex items-center gap-2'>
<Label htmlFor='tag-name'>Tag Name</Label>
{editingTagIndex !== null && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='h-4 w-4 cursor-help text-muted-foreground' />
</Tooltip.Trigger>
<Tooltip.Content>
<p className='text-sm'>
Changing this tag name will update it for all documents in this knowledge
base
</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
<div className='flex gap-2'>
<Input
id='tag-name'
value={editForm.displayName}
onChange={(e) => setEditForm({ ...editForm, displayName: e.target.value })}
placeholder='Enter tag name'
className='flex-1'
/>
{editingTagIndex === null && availableDefinitions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm'>
<ChevronDown className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{availableDefinitions.map((def) => (
<DropdownMenuItem
key={def.id}
onClick={() =>
setEditForm({
...editForm,
displayName: def.displayName,
fieldType: def.fieldType,
})
}
>
{def.displayName}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Tag Type */}
<div className='space-y-2'>
<Label htmlFor='tag-type'>Type</Label>
<Select
value={editForm.fieldType}
onValueChange={(value) => setEditForm({ ...editForm, fieldType: value })}
disabled={editingTagIndex !== null} // Disable in edit mode
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='text'>Text</SelectItem>
</SelectContent>
</Select>
</div>
{/* Tag Value */}
<div className='space-y-2'>
<Label htmlFor='tag-value'>Value</Label>
<Input
id='tag-value'
value={editForm.value}
onChange={(e) => setEditForm({ ...editForm, value: e.target.value })}
placeholder='Enter tag value'
/>
</div>
</div>
{/* Show warning when at max slots in create mode */}
{editingTagIndex === null && kbTagDefinitions.length >= MAX_TAG_SLOTS && (
<div className='rounded-md border border-amber-200 bg-amber-50 p-3'>
<div className='flex items-center gap-2 text-amber-800 text-sm'>
<span className='font-medium'>Maximum tag definitions reached</span>
</div>
<p className='mt-1 text-amber-700 text-xs'>
You can still use existing tag definitions from the dropdown, but cannot create new
ones.
</p>
</div>
)}
<div className='flex justify-end gap-2 pt-4'>
<Button variant='outline' onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button
onClick={saveTagFromModal}
disabled={(() => {
if (!editForm.displayName.trim()) return true
// In edit mode, always allow
if (editingTagIndex !== null) return false
// In create mode, check if we're creating a new definition at max slots
const existingDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
)
// If using existing definition, allow
if (existingDefinition) return false
// If creating new definition and at max slots, disable
return kbTagDefinitions.length >= MAX_TAG_SLOTS
})()}
>
{(() => {
if (editingTagIndex !== null) {
return 'Save Changes'
}
const existingDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
)
if (existingDefinition) {
return 'Use Existing Tag'
}
if (kbTagDefinitions.length >= MAX_TAG_SLOTS) {
return 'Max Tags Reached'
}
return 'Create New Tag'
})()}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,39 +0,0 @@
'use client'
import { LibraryBig } from 'lucide-react'
interface EmptyStateCardProps {
title: string
description: string
buttonText: string
onClick: () => void
icon?: React.ReactNode
}
export function EmptyStateCard({
title,
description,
buttonText,
onClick,
icon,
}: EmptyStateCardProps) {
return (
<div
onClick={onClick}
className='group flex cursor-pointer flex-col gap-3 rounded-md border border-muted-foreground/25 border-dashed bg-background p-4 transition-colors hover:border-muted-foreground/40 hover:bg-accent/50'
>
<div className='flex items-center gap-2'>
{icon || <LibraryBig className='h-4 w-4 flex-shrink-0 text-muted-foreground' />}
<h3 className='truncate font-medium text-sm leading-tight'>{title}</h3>
</div>
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2 text-muted-foreground text-xs'>
<span>Get started</span>
</div>
<p className='line-clamp-2 overflow-hidden text-muted-foreground text-xs'>{description}</p>
</div>
</div>
)
}

View File

@@ -1,11 +1,4 @@
export { BaseOverview } from './base-overview/base-overview'
export { CreateModal } from './create-modal/create-modal'
export { EmptyStateCard } from './empty-state-card/empty-state-card'
export { BaseCard, BaseCardSkeleton, BaseCardSkeletonGrid } from './base-card/base-card'
export { CreateBaseModal } from './create-base-modal/create-base-modal'
export { getDocumentIcon } from './icons/document-icons'
export { KnowledgeHeader } from './knowledge-header/knowledge-header'
export { PrimaryButton } from './primary-button/primary-button'
export { SearchInput } from './search-input/search-input'
export { KnowledgeBaseCardSkeletonGrid } from './skeletons/knowledge-base-card-skeleton'
export { ChunkTableSkeleton, DocumentTableSkeleton } from './skeletons/table-skeleton'
export { type TagData, TagInput } from './tag-input/tag-input'
export { WorkspaceSelector } from './workspace-selector/workspace-selector'

View File

@@ -1,12 +1,22 @@
'use client'
import { useState } from 'react'
import { LibraryBig, MoreHorizontal } from 'lucide-react'
import { useEffect, useState } from 'react'
import { AlertTriangle, ChevronDown, LibraryBig, MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
import {
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { WorkspaceSelector } from '@/app/workspace/[workspaceId]/knowledge/components'
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/shared'
import { createLogger } from '@/lib/logs/console/logger'
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
import { useKnowledgeStore } from '@/stores/knowledge/store'
const logger = createLogger('KnowledgeHeader')
interface BreadcrumbItem {
label: string
@@ -27,7 +37,7 @@ const HEADER_STYLES = {
interface KnowledgeHeaderOptions {
knowledgeBaseId?: string
currentWorkspaceId?: string | null
onWorkspaceChange?: (workspaceId: string | null) => void
onWorkspaceChange?: (workspaceId: string | null) => void | Promise<void>
onDeleteKnowledgeBase?: () => void
}
@@ -36,8 +46,101 @@ interface KnowledgeHeaderProps {
options?: KnowledgeHeaderOptions
}
interface Workspace {
id: string
name: string
permissions: 'admin' | 'write' | 'read'
}
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
const { updateKnowledgeBase } = useKnowledgeStore()
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false)
const [isWorkspacePopoverOpen, setIsWorkspacePopoverOpen] = useState(false)
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
const [isUpdatingWorkspace, setIsUpdatingWorkspace] = useState(false)
// Fetch available workspaces
useEffect(() => {
if (!options?.knowledgeBaseId) return
const fetchWorkspaces = async () => {
try {
setIsLoadingWorkspaces(true)
const response = await fetch('/api/workspaces')
if (!response.ok) {
throw new Error('Failed to fetch workspaces')
}
const data = await response.json()
// Filter workspaces where user has write/admin permissions
const availableWorkspaces = data.workspaces
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
.map((ws: any) => ({
id: ws.id,
name: ws.name,
permissions: ws.permissions,
}))
setWorkspaces(availableWorkspaces)
} catch (err) {
logger.error('Error fetching workspaces:', err)
} finally {
setIsLoadingWorkspaces(false)
}
}
fetchWorkspaces()
}, [options?.knowledgeBaseId])
const handleWorkspaceChange = async (workspaceId: string | null) => {
if (isUpdatingWorkspace || !options?.knowledgeBaseId) return
try {
setIsUpdatingWorkspace(true)
setIsWorkspacePopoverOpen(false)
const response = await fetch(`/api/knowledge/${options.knowledgeBaseId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId,
}),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update workspace')
}
const result = await response.json()
if (result.success) {
logger.info(
`Knowledge base workspace updated: ${options.knowledgeBaseId} -> ${workspaceId}`
)
// Notify parent component of the change to refresh data
await options.onWorkspaceChange?.(workspaceId)
// Update the store after refresh to ensure consistency
updateKnowledgeBase(options.knowledgeBaseId, { workspaceId: workspaceId || undefined })
} else {
throw new Error(result.error || 'Failed to update workspace')
}
} catch (err) {
logger.error('Error updating workspace:', err)
} finally {
setIsUpdatingWorkspace(false)
}
}
const currentWorkspace = workspaces.find((ws) => ws.id === options?.currentWorkspaceId)
const hasWorkspace = !!options?.currentWorkspaceId
return (
<div className={HEADER_STYLES.container}>
@@ -69,11 +172,67 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
<div className={HEADER_STYLES.actionsContainer}>
{/* Workspace Selector */}
{options.knowledgeBaseId && (
<WorkspaceSelector
knowledgeBaseId={options.knowledgeBaseId}
currentWorkspaceId={options.currentWorkspaceId || null}
onWorkspaceChange={options.onWorkspaceChange}
/>
<div className='flex items-center gap-2'>
{/* Warning icon for unassigned knowledge bases */}
{!hasWorkspace && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<AlertTriangle className='h-4 w-4 text-amber-500' />
</Tooltip.Trigger>
<Tooltip.Content side='top'>Not assigned to workspace</Tooltip.Content>
</Tooltip.Root>
)}
{/* Workspace selector dropdown */}
<Popover open={isWorkspacePopoverOpen} onOpenChange={setIsWorkspacePopoverOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
disabled={isLoadingWorkspaces || isUpdatingWorkspace}
className={filterButtonClass}
>
<span className='truncate'>
{isLoadingWorkspaces
? 'Loading...'
: isUpdatingWorkspace
? 'Updating...'
: currentWorkspace?.name || 'No workspace'}
</span>
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={4}>
{/* No workspace option */}
<PopoverItem
active={!options.currentWorkspaceId}
showCheck
onClick={() => handleWorkspaceChange(null)}
>
<span className='text-muted-foreground'>No workspace</span>
</PopoverItem>
{/* Available workspaces */}
{workspaces.map((workspace) => (
<PopoverItem
key={workspace.id}
active={options.currentWorkspaceId === workspace.id}
showCheck
onClick={() => handleWorkspaceChange(workspace.id)}
>
{workspace.name}
</PopoverItem>
))}
{workspaces.length === 0 && !isLoadingWorkspaces && (
<PopoverItem disabled>
<span className='text-muted-foreground text-xs'>
No workspaces with write access
</span>
</PopoverItem>
)}
</PopoverContent>
</Popover>
</div>
)}
{/* Actions Menu */}

View File

@@ -1,36 +0,0 @@
'use client'
import { Button } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
interface PrimaryButtonProps {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
className?: string
type?: 'button' | 'submit' | 'reset'
}
export function PrimaryButton({
children,
onClick,
disabled = false,
className,
type = 'button',
}: PrimaryButtonProps) {
return (
<Button
type={type}
onClick={onClick}
disabled={disabled}
variant='primary'
className={cn(
'flex h-8 items-center gap-1 px-[8px] py-[6px] font-[480] shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
disabled && 'disabled:cursor-not-allowed disabled:opacity-50',
className
)}
>
{children}
</Button>
)
}

View File

@@ -1,52 +0,0 @@
'use client'
import { Search, X } from 'lucide-react'
interface SearchInputProps {
value: string
onChange: (value: string) => void
placeholder: string
disabled?: boolean
className?: string
isLoading?: boolean
}
export function SearchInput({
value,
onChange,
placeholder,
disabled = false,
className = 'max-w-md flex-1',
isLoading = false,
}: SearchInputProps) {
return (
<div className={`relative ${className}`}>
<div className='relative flex items-center'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
<input
type='text'
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className='h-10 w-full rounded-md border bg-background px-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
/>
{isLoading ? (
<div className='-translate-y-1/2 absolute top-1/2 right-3'>
<div className='h-[18px] w-[18px] animate-spin rounded-full border-2 border-gray-300 border-t-[var(--brand-primary-hex)]' />
</div>
) : (
value &&
!disabled && (
<button
onClick={() => onChange('')}
className='-translate-y-1/2 absolute top-1/2 right-3 transform text-muted-foreground hover:text-foreground'
>
<X className='h-[18px] w-[18px]' />
</button>
)
)}
</div>
</div>
)
}

View File

@@ -1,40 +0,0 @@
export function KnowledgeBaseCardSkeleton() {
return (
<div className='rounded-lg border bg-background p-4'>
<div className='flex items-start justify-between'>
<div className='flex-1 space-y-3'>
{/* Title skeleton */}
<div className='h-4 w-3/4 animate-pulse rounded bg-muted' />
{/* Description skeleton */}
<div className='space-y-2'>
<div className='h-3 w-full animate-pulse rounded bg-muted' />
<div className='h-3 w-2/3 animate-pulse rounded bg-muted' />
</div>
{/* Stats skeleton */}
<div className='flex items-center gap-4 pt-2'>
<div className='flex items-center gap-1'>
<div className='h-3 w-3 animate-pulse rounded bg-muted' />
<div className='h-3 w-8 animate-pulse rounded bg-muted' />
</div>
<div className='flex items-center gap-1'>
<div className='h-3 w-3 animate-pulse rounded bg-muted' />
<div className='h-3 w-12 animate-pulse rounded bg-muted' />
</div>
</div>
</div>
</div>
</div>
)
}
export function KnowledgeBaseCardSkeletonGrid({ count = 8 }: { count?: number }) {
return (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{Array.from({ length: count }).map((_, i) => (
<KnowledgeBaseCardSkeleton key={i} />
))}
</div>
)
}

View File

@@ -1,242 +0,0 @@
export function DocumentTableRowSkeleton({ isSidebarCollapsed }: { isSidebarCollapsed: boolean }) {
return (
<tr className='border-b'>
{/* Select column */}
<td className='px-4 py-3'>
<div className='h-3.5 w-3.5 animate-pulse rounded bg-muted' />
</td>
{/* Name column */}
<td className='px-4 py-3'>
<div className='flex items-center gap-2'>
<div className='h-6 w-5 animate-pulse rounded bg-muted' />
<div className='h-4 w-32 animate-pulse rounded bg-muted' />
</div>
</td>
{/* Size column */}
<td className='px-4 py-3'>
<div className='h-3 w-12 animate-pulse rounded bg-muted' />
</td>
{/* Tokens column */}
<td className='px-4 py-3'>
<div className='h-3 w-8 animate-pulse rounded bg-muted' />
</td>
{/* Chunks column - hidden on small screens */}
<td className='hidden px-4 py-3 lg:table-cell'>
<div className='h-3 w-6 animate-pulse rounded bg-muted' />
</td>
{/* Upload Time column */}
<td className='px-4 py-3'>
<div className='space-y-1'>
<div className='h-3 w-16 animate-pulse rounded bg-muted' />
<div className='h-3 w-12 animate-pulse rounded bg-muted lg:hidden' />
</div>
</td>
{/* Status column */}
<td className='px-4 py-3'>
<div className='h-6 w-16 animate-pulse rounded-md bg-muted' />
</td>
{/* Actions column */}
<td className='px-4 py-3'>
<div className='flex items-center gap-1'>
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
</div>
</td>
</tr>
)
}
export function ChunkTableRowSkeleton({ isSidebarCollapsed }: { isSidebarCollapsed: boolean }) {
return (
<tr className='border-b'>
{/* Select column */}
<td className='px-4 py-3'>
<div className='h-3.5 w-3.5 animate-pulse rounded bg-muted' />
</td>
{/* Index column */}
<td className='px-4 py-3'>
<div className='h-4 w-6 animate-pulse rounded bg-muted' />
</td>
{/* Content column */}
<td className='px-4 py-3'>
<div className='space-y-2'>
<div className='h-4 w-full animate-pulse rounded bg-muted' />
<div className='h-4 w-3/4 animate-pulse rounded bg-muted' />
<div className='h-4 w-1/2 animate-pulse rounded bg-muted' />
</div>
</td>
{/* Tokens column */}
<td className='px-4 py-3'>
<div className='h-3 w-8 animate-pulse rounded bg-muted' />
</td>
{/* Status column */}
<td className='px-4 py-3'>
<div className='h-6 w-16 animate-pulse rounded-md bg-muted' />
</td>
{/* Actions column */}
<td className='px-4 py-3'>
<div className='flex items-center gap-1'>
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
</div>
</td>
</tr>
)
}
export function DocumentTableSkeleton({
isSidebarCollapsed,
rowCount = 5,
}: {
isSidebarCollapsed: boolean
rowCount?: number
}) {
return (
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Table header - fixed */}
<div className='sticky top-0 z-10 overflow-x-auto border-b bg-background'>
<table className='w-full min-w-[700px] table-fixed'>
<colgroup>
<col className='w-[4%]' />
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[24%]'}`} />
<col className='w-[8%]' />
<col className='w-[8%]' />
<col className='hidden w-[8%] lg:table-column' />
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[16%]'}`} />
<col className='w-[12%]' />
<col className='w-[14%]' />
</colgroup>
<thead>
<tr>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<div className='h-3.5 w-3.5 animate-pulse rounded bg-muted' />
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Name</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Size</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Tokens</span>
</th>
<th className='hidden px-4 pt-2 pb-3 text-left font-medium lg:table-cell'>
<span className='text-muted-foreground text-xs leading-none'>Chunks</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Uploaded</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Status</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Actions</span>
</th>
</tr>
</thead>
</table>
</div>
{/* Table body - scrollable */}
<div className='flex-1 overflow-auto'>
<table className='w-full min-w-[700px] table-fixed'>
<colgroup>
<col className='w-[4%]' />
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[24%]'}`} />
<col className='w-[8%]' />
<col className='w-[8%]' />
<col className='hidden w-[8%] lg:table-column' />
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[16%]'}`} />
<col className='w-[12%]' />
<col className='w-[14%]' />
</colgroup>
<tbody>
{Array.from({ length: rowCount }).map((_, i) => (
<DocumentTableRowSkeleton key={i} isSidebarCollapsed={isSidebarCollapsed} />
))}
</tbody>
</table>
</div>
</div>
)
}
export function ChunkTableSkeleton({
isSidebarCollapsed,
rowCount = 5,
}: {
isSidebarCollapsed: boolean
rowCount?: number
}) {
return (
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Table header - fixed */}
<div className='sticky top-0 z-10 border-b bg-background'>
<table className='w-full table-fixed'>
<colgroup>
<col className='w-[5%]' />
<col className='w-[8%]' />
<col className={`${isSidebarCollapsed ? 'w-[57%]' : 'w-[55%]'}`} />
<col className='w-[10%]' />
<col className='w-[10%]' />
<col className='w-[12%]' />
</colgroup>
<thead>
<tr>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<div className='h-3.5 w-3.5 animate-pulse rounded bg-muted' />
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Index</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Content</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Tokens</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Status</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Actions</span>
</th>
</tr>
</thead>
</table>
</div>
{/* Table body - scrollable */}
<div className='flex-1 overflow-auto'>
<table className='w-full table-fixed'>
<colgroup>
<col className='w-[5%]' />
<col className='w-[8%]' />
<col className={`${isSidebarCollapsed ? 'w-[57%]' : 'w-[55%]'}`} />
<col className='w-[10%]' />
<col className='w-[10%]' />
<col className='w-[12%]' />
</colgroup>
<tbody>
{Array.from({ length: rowCount }).map((_, i) => (
<ChunkTableRowSkeleton key={i} isSidebarCollapsed={isSidebarCollapsed} />
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -1,197 +0,0 @@
'use client'
import { useState } from 'react'
import { ChevronDown, ChevronRight, Plus, Settings, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { TAG_SLOTS, type TagSlot } from '@/lib/knowledge/constants'
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
export type TagData = {
[K in TagSlot]?: string
}
interface TagInputProps {
tags: TagData
onTagsChange: (tags: TagData) => void
disabled?: boolean
className?: string
knowledgeBaseId?: string | null
documentId?: string | null
}
const TAG_LABELS = TAG_SLOTS.map((slot, index) => ({
key: slot as keyof TagData,
label: `Tag ${index + 1}`,
placeholder: 'Enter tag value',
}))
export function TagInput({
tags,
onTagsChange,
disabled = false,
className = '',
knowledgeBaseId = null,
documentId = null,
}: TagInputProps) {
const [isOpen, setIsOpen] = useState(false)
const [showAllTags, setShowAllTags] = useState(false)
// Use custom tag definitions if available
const { getTagLabel } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const handleTagChange = (tagKey: keyof TagData, value: string) => {
onTagsChange({
...tags,
[tagKey]: value.trim() || undefined,
})
}
const clearTag = (tagKey: keyof TagData) => {
onTagsChange({
...tags,
[tagKey]: undefined,
})
}
const hasAnyTags = Object.values(tags).some((tag) => tag?.trim())
// Create tag labels using custom definitions or fallback to defaults
const tagLabels = TAG_LABELS.map(({ key, placeholder }) => ({
key,
label: getTagLabel(key),
placeholder,
}))
const visibleTags = showAllTags ? tagLabels : tagLabels.slice(0, 2)
return (
<div className={className}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
type='button'
variant='ghost'
className='flex h-auto w-full justify-between p-0 hover:bg-transparent'
>
<div className='flex items-center gap-2'>
<Settings className='h-4 w-4 text-muted-foreground' />
<Label className='cursor-pointer font-medium text-sm'>Advanced Settings</Label>
{hasAnyTags && (
<span className='rounded-full bg-primary/10 px-2 py-0.5 text-muted-foreground text-xs'>
{Object.values(tags).filter((tag) => tag?.trim()).length} tag
{Object.values(tags).filter((tag) => tag?.trim()).length !== 1 ? 's' : ''}
</span>
)}
</div>
{isOpen ? (
<ChevronDown className='h-4 w-4 text-muted-foreground' />
) : (
<ChevronRight className='h-4 w-4 text-muted-foreground' />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className='space-y-4 pt-4'>
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<Label className='font-medium text-sm'>Document Tags</Label>
{!showAllTags && (
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => setShowAllTags(true)}
className='h-auto p-1 text-muted-foreground text-xs hover:text-foreground'
>
<Plus className='mr-1 h-3 w-3' />
More tags
</Button>
)}
</div>
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
{visibleTags.map(({ key, label, placeholder }) => (
<div key={key} className='space-y-1'>
<Label htmlFor={key} className='text-muted-foreground text-xs'>
{label}
</Label>
<div className='relative'>
<Input
id={key}
type='text'
value={tags[key] || ''}
onChange={(e) => handleTagChange(key, e.target.value)}
placeholder={placeholder}
disabled={disabled}
className='pr-8 text-sm'
/>
{tags[key] && (
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => clearTag(key)}
disabled={disabled}
className='-translate-y-1/2 absolute top-1/2 right-1 h-6 w-6 p-0 hover:bg-muted'
>
<X className='h-3 w-3' />
</Button>
)}
</div>
</div>
))}
</div>
{showAllTags && (
<div className='flex justify-center'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => setShowAllTags(false)}
className='h-auto p-1 text-muted-foreground text-xs hover:text-foreground'
>
Show fewer tags
</Button>
</div>
)}
{hasAnyTags && (
<div className='rounded-md bg-muted/50 p-3'>
<p className='mb-2 text-muted-foreground text-xs'>Active tags:</p>
<div className='flex flex-wrap gap-1'>
{Object.entries(tags).map(([key, value]) => {
if (!value?.trim()) return null
const tagLabel = getTagLabel(key)
return (
<span
key={key}
className='inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-muted-foreground text-xs'
>
<span className='font-medium'>{tagLabel}:</span>
<span>{value}</span>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => clearTag(key as keyof TagData)}
disabled={disabled}
className='h-3 w-3 p-0 hover:bg-primary/20'
>
<X className='h-2 w-2' />
</Button>
</span>
)
})}
</div>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
)
}

View File

@@ -1,183 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { AlertTriangle, ChevronDown } from 'lucide-react'
import {
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/shared'
import { useKnowledgeStore } from '@/stores/knowledge/store'
const logger = createLogger('WorkspaceSelector')
interface Workspace {
id: string
name: string
permissions: 'admin' | 'write' | 'read'
}
interface WorkspaceSelectorProps {
knowledgeBaseId: string
currentWorkspaceId: string | null
onWorkspaceChange?: (workspaceId: string | null) => void
disabled?: boolean
}
export function WorkspaceSelector({
knowledgeBaseId,
currentWorkspaceId,
onWorkspaceChange,
disabled = false,
}: WorkspaceSelectorProps) {
const { updateKnowledgeBase } = useKnowledgeStore()
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isUpdating, setIsUpdating] = useState(false)
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
// Fetch available workspaces
useEffect(() => {
const fetchWorkspaces = async () => {
try {
setIsLoading(true)
const response = await fetch('/api/workspaces')
if (!response.ok) {
throw new Error('Failed to fetch workspaces')
}
const data = await response.json()
// Filter workspaces where user has write/admin permissions
const availableWorkspaces = data.workspaces
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
.map((ws: any) => ({
id: ws.id,
name: ws.name,
permissions: ws.permissions,
}))
setWorkspaces(availableWorkspaces)
} catch (err) {
logger.error('Error fetching workspaces:', err)
} finally {
setIsLoading(false)
}
}
fetchWorkspaces()
}, [])
const handleWorkspaceChange = async (workspaceId: string | null) => {
if (isUpdating || disabled) return
try {
setIsUpdating(true)
setIsPopoverOpen(false)
const response = await fetch(`/api/knowledge/${knowledgeBaseId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId,
}),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to update workspace')
}
const result = await response.json()
if (result.success) {
logger.info(`Knowledge base workspace updated: ${knowledgeBaseId} -> ${workspaceId}`)
// Notify parent component of the change to refresh data
await onWorkspaceChange?.(workspaceId)
// Update the store after refresh to ensure consistency
updateKnowledgeBase(knowledgeBaseId, { workspaceId: workspaceId || undefined })
} else {
throw new Error(result.error || 'Failed to update workspace')
}
} catch (err) {
logger.error('Error updating workspace:', err)
} finally {
setIsUpdating(false)
}
}
const currentWorkspace = workspaces.find((ws) => ws.id === currentWorkspaceId)
const hasWorkspace = !!currentWorkspaceId
return (
<div className='flex items-center gap-2'>
{/* Warning icon for unassigned knowledge bases */}
{!hasWorkspace && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<AlertTriangle className='h-4 w-4 text-amber-500' />
</Tooltip.Trigger>
<Tooltip.Content side='top'>Not assigned to workspace</Tooltip.Content>
</Tooltip.Root>
)}
{/* Workspace selector dropdown */}
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
disabled={disabled || isLoading || isUpdating}
className={filterButtonClass}
>
<span className='truncate'>
{isLoading
? 'Loading...'
: isUpdating
? 'Updating...'
: currentWorkspace?.name || 'No workspace'}
</span>
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={4}>
{/* No workspace option */}
<PopoverItem
active={!currentWorkspaceId}
showCheck
onClick={() => handleWorkspaceChange(null)}
>
<span className='text-muted-foreground'>No workspace</span>
</PopoverItem>
{/* Available workspaces */}
{workspaces.map((workspace) => (
<PopoverItem
key={workspace.id}
active={currentWorkspaceId === workspace.id}
showCheck
onClick={() => handleWorkspaceChange(workspace.id)}
>
{workspace.name}
</PopoverItem>
))}
{workspaces.length === 0 && !isLoading && (
<PopoverItem disabled>
<span className='text-muted-foreground text-xs'>No workspaces with write access</span>
</PopoverItem>
)}
</PopoverContent>
</Popover>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('KnowledgeUpload')
@@ -84,15 +84,18 @@ class ProcessingError extends KnowledgeUploadError {
}
}
/**
* Configuration constants for file upload operations
*/
const UPLOAD_CONFIG = {
MAX_PARALLEL_UPLOADS: 3, // Prevent client saturation mirrors guidance on limiting simultaneous transfers (@Web)
MAX_PARALLEL_UPLOADS: 3,
MAX_RETRIES: 3,
RETRY_DELAY_MS: 2000,
RETRY_BACKOFF: 2,
CHUNK_SIZE: 8 * 1024 * 1024, // 8MB keeps us well above S3 minimum part size while reducing part count (@Web)
CHUNK_SIZE: 8 * 1024 * 1024,
DIRECT_UPLOAD_THRESHOLD: 4 * 1024 * 1024,
LARGE_FILE_THRESHOLD: 50 * 1024 * 1024,
BASE_TIMEOUT_MS: 2 * 60 * 1000, // baseline per transfer window per large-file guidance (@Web)
BASE_TIMEOUT_MS: 2 * 60 * 1000,
TIMEOUT_PER_MB_MS: 1500,
MAX_TIMEOUT_MS: 10 * 60 * 1000,
MULTIPART_PART_CONCURRENCY: 3,
@@ -100,28 +103,49 @@ const UPLOAD_CONFIG = {
BATCH_REQUEST_SIZE: 50,
} as const
/**
* Calculates the upload timeout based on file size
*/
const calculateUploadTimeoutMs = (fileSize: number) => {
const sizeInMb = fileSize / (1024 * 1024)
const dynamicBudget = UPLOAD_CONFIG.BASE_TIMEOUT_MS + sizeInMb * UPLOAD_CONFIG.TIMEOUT_PER_MB_MS
return Math.min(dynamicBudget, UPLOAD_CONFIG.MAX_TIMEOUT_MS)
}
/**
* Delays execution for the specified duration
*/
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
/**
* Gets high resolution timestamp for performance measurements
*/
const getHighResTime = () =>
typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
: Date.now()
/**
* Formats bytes to megabytes with 2 decimal places
*/
const formatMegabytes = (bytes: number) => Number((bytes / (1024 * 1024)).toFixed(2))
/**
* Calculates throughput in Mbps
*/
const calculateThroughputMbps = (bytes: number, durationMs: number) => {
if (!bytes || !durationMs) return 0
return Number((((bytes * 8) / durationMs) * 0.001).toFixed(2))
}
/**
* Formats duration from milliseconds to seconds
*/
const formatDurationSeconds = (durationMs: number) => Number((durationMs / 1000).toFixed(2))
/**
* Runs async operations with concurrency limit
*/
const runWithConcurrency = async <T, R>(
items: T[],
limit: number,
@@ -156,14 +180,26 @@ const runWithConcurrency = async <T, R>(
return results
}
/**
* Extracts the error name from an unknown error object
*/
const getErrorName = (error: unknown) =>
typeof error === 'object' && error !== null && 'name' in error ? String((error as any).name) : ''
/**
* Extracts a human-readable message from an unknown error
*/
const getErrorMessage = (error: unknown) =>
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
/**
* Checks if an error is an abort error
*/
const isAbortError = (error: unknown) => getErrorName(error) === 'AbortError'
/**
* Checks if an error is a network-related error
*/
const isNetworkError = (error: unknown) => {
if (!(error instanceof Error)) {
return false
@@ -197,6 +233,9 @@ interface PresignedUploadInfo {
presignedUrls?: any
}
/**
* Normalizes presigned URL response data into a consistent format
*/
const normalizePresignedData = (data: any, context: string): PresignedUploadInfo => {
const presignedUrl = data?.presignedUrl || data?.uploadUrl
const fileInfo = data?.fileInfo
@@ -221,6 +260,9 @@ const normalizePresignedData = (data: any, context: string): PresignedUploadInfo
}
}
/**
* Fetches presigned URL data for file upload
*/
const getPresignedData = async (
file: File,
timeoutMs: number,
@@ -249,7 +291,7 @@ const getPresignedData = async (
try {
errorDetails = await presignedResponse.json()
} catch {
// Ignore JSON parsing errors (@Web)
errorDetails = null
}
logger.error('Presigned URL request failed', {
@@ -279,6 +321,9 @@ const getPresignedData = async (
}
}
/**
* Hook for managing file uploads to knowledge bases
*/
export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState<UploadProgress>({
@@ -288,6 +333,9 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
})
const [uploadError, setUploadError] = useState<UploadError | null>(null)
/**
* Creates an UploadedFile object from file metadata
*/
const createUploadedFile = (
filename: string,
fileUrl: string,
@@ -299,7 +347,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
fileUrl,
fileSize,
mimeType,
// Include tags from original file if available
tag1: (originalFile as any)?.tag1,
tag2: (originalFile as any)?.tag2,
tag3: (originalFile as any)?.tag3,
@@ -309,6 +356,9 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
tag7: (originalFile as any)?.tag7,
})
/**
* Creates an UploadError from an exception
*/
const createErrorFromException = (error: unknown, defaultMessage: string): UploadError => {
if (error instanceof KnowledgeUploadError) {
return {
@@ -356,13 +406,11 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
try {
// For large files (>50MB), use multipart upload
if (file.size > UPLOAD_CONFIG.LARGE_FILE_THRESHOLD) {
presignedData = presignedOverride ?? (await getPresignedData(file, timeoutMs, controller))
return await uploadFileInChunks(file, presignedData, timeoutMs, fileIndex)
}
// For all other files, use server-side upload
return await uploadFileThroughAPI(file, timeoutMs)
} finally {
clearTimeout(timeoutId)
@@ -372,7 +420,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
const isNetwork = isNetworkError(error)
if (retryCount < UPLOAD_CONFIG.MAX_RETRIES) {
const delay = UPLOAD_CONFIG.RETRY_DELAY_MS * UPLOAD_CONFIG.RETRY_BACKOFF ** retryCount // More aggressive exponential backoff (@Web)
const delay = UPLOAD_CONFIG.RETRY_DELAY_MS * UPLOAD_CONFIG.RETRY_BACKOFF ** retryCount
if (isTimeout || isNetwork) {
logger.warn(
`Upload failed (${isTimeout ? 'timeout' : 'network'}), retrying in ${delay / 1000}s...`,
@@ -446,7 +494,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
outerController.signal.addEventListener('abort', abortHandler)
// Track upload progress
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && fileIndex !== undefined && !isCompleted) {
const percentComplete = Math.round((event.loaded / event.total) * 100)
@@ -517,10 +564,8 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
xhr.addEventListener('abort', abortHandler)
// Start the upload
xhr.open('PUT', presignedData.presignedUrl)
// Set headers
xhr.setRequestHeader('Content-Type', file.type)
if (presignedData.uploadHeaders) {
Object.entries(presignedData.uploadHeaders).forEach(([key, value]) => {
@@ -547,7 +592,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
const startTime = getHighResTime()
try {
// Step 1: Initiate multipart upload
const initiateResponse = await fetch('/api/files/multipart?action=initiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -565,12 +609,10 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
const { uploadId, key } = await initiateResponse.json()
logger.info(`Initiated multipart upload with ID: ${uploadId}`)
// Step 2: Calculate parts
const chunkSize = UPLOAD_CONFIG.CHUNK_SIZE
const numParts = Math.ceil(file.size / chunkSize)
const partNumbers = Array.from({ length: numParts }, (_, i) => i + 1)
// Step 3: Get presigned URLs for all parts
const partUrlsResponse = await fetch('/api/files/multipart?action=get-part-urls', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -593,7 +635,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
const { presignedUrls } = await partUrlsResponse.json()
// Step 4: Upload parts in parallel (batch them to avoid overwhelming the browser)
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = []
const controller = new AbortController()
@@ -667,7 +708,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
clearTimeout(multipartTimeoutId)
}
// Step 5: Complete multipart upload
const completeResponse = await fetch('/api/files/multipart?action=complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -705,7 +745,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
sizeMB: formatMegabytes(file.size),
durationMs: formatDurationSeconds(durationMs),
})
// Fall back to direct upload if multipart fails
return uploadFileDirectly(file, presignedData, timeoutMs, new AbortController(), fileIndex)
}
}
@@ -737,7 +776,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
try {
errorData = await uploadResponse.json()
} catch {
// Ignore JSON parsing errors
errorData = null
}
throw new DirectUploadError(
@@ -772,11 +811,13 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
/**
* Upload files using batch presigned URLs (works for both S3 and Azure Blob)
*/
/**
* Uploads files in batches using presigned URLs
*/
const uploadFilesInBatches = async (files: File[]): Promise<UploadedFile[]> => {
const results: UploadedFile[] = []
const failedFiles: Array<{ file: File; error: Error }> = []
// Initialize file statuses
const fileStatuses: FileUploadStatus[] = files.map((file) => ({
fileName: file.name,
fileSize: file.size,
@@ -925,6 +966,9 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
}
}
/**
* Main upload function that handles file uploads and document processing
*/
const uploadFiles = async (
files: File[],
knowledgeBaseId: string,
@@ -950,7 +994,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
const processPayload = {
documents: uploadedFiles.map((file) => ({
...file,
// Tags are already included in the file object from createUploadedFile
})),
processingOptions: {
chunkSize: processingOptions.chunkSize || 1024,
@@ -975,7 +1018,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
try {
errorData = await processResponse.json()
} catch {
// Ignore JSON parsing errors
errorData = null
}
logger.error('Document processing failed:', {
@@ -1034,9 +1077,12 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
}
}
const clearError = () => {
/**
* Clears the current upload error
*/
const clearError = useCallback(() => {
setUploadError(null)
}
}, [])
return {
isUploading,

View File

@@ -1,7 +1,7 @@
'use client'
import { useMemo, useState } from 'react'
import { ChevronDown, LibraryBig, Plus } from 'lucide-react'
import { ChevronDown, Database, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -11,32 +11,37 @@ import {
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { Input } from '@/components/ui/input'
import {
BaseOverview,
CreateModal,
EmptyStateCard,
KnowledgeBaseCardSkeletonGrid,
KnowledgeHeader,
SearchInput,
BaseCard,
BaseCardSkeletonGrid,
CreateBaseModal,
} from '@/app/workspace/[workspaceId]/knowledge/components'
import {
filterButtonClass,
SORT_OPTIONS,
type SortOption,
type SortOrder,
} from '@/app/workspace/[workspaceId]/knowledge/components/shared'
} from '@/app/workspace/[workspaceId]/knowledge/components/constants'
import {
filterKnowledgeBases,
sortKnowledgeBases,
} from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useDebounce } from '@/hooks/use-debounce'
import { useKnowledgeBasesList } from '@/hooks/use-knowledge'
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
/**
* Extended knowledge base data with document count
*/
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
docCount?: number
}
/**
* Knowledge base list component displaying all knowledge bases in a workspace
* Supports filtering by search query and sorting options
*/
export function Knowledge() {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -46,6 +51,7 @@ export function Knowledge() {
const userPermissions = useUserPermissionsContext()
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isSortPopoverOpen, setIsSortPopoverOpen] = useState(false)
const [sortBy, setSortBy] = useState<SortOption>('updatedAt')
@@ -55,6 +61,9 @@ export function Knowledge() {
const currentSortLabel =
SORT_OPTIONS.find((opt) => opt.value === currentSortValue)?.label || 'Last Updated'
/**
* Handles sort option change from dropdown
*/
const handleSortChange = (value: string) => {
const [field, order] = value.split('-') as [SortOption, SortOrder]
setSortBy(field)
@@ -62,19 +71,32 @@ export function Knowledge() {
setIsSortPopoverOpen(false)
}
/**
* Callback when a new knowledge base is created
*/
const handleKnowledgeBaseCreated = (newKnowledgeBase: KnowledgeBaseData) => {
addKnowledgeBase(newKnowledgeBase)
}
/**
* Retry loading knowledge bases after an error
*/
const handleRetry = () => {
refreshList()
}
/**
* Filter and sort knowledge bases based on search query and sort options
* Memoized to prevent unnecessary recalculations on render
*/
const filteredAndSortedKnowledgeBases = useMemo(() => {
const filtered = filterKnowledgeBases(knowledgeBases, searchQuery)
const filtered = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery)
return sortKnowledgeBases(filtered, sortBy, sortOrder)
}, [knowledgeBases, searchQuery, sortBy, sortOrder])
}, [knowledgeBases, debouncedSearchQuery, sortBy, sortOrder])
/**
* Format knowledge base data for display in the card
*/
const formatKnowledgeBaseForDisplay = (kb: KnowledgeBaseWithDocCount) => ({
id: kb.id,
title: kb.name,
@@ -84,146 +106,144 @@ export function Knowledge() {
updatedAt: kb.updatedAt,
})
const breadcrumbs = [{ id: 'knowledge', label: 'Knowledge' }]
/**
* Get empty state content based on current filters
* Memoized to prevent unnecessary recalculations on render
*/
const emptyState = useMemo(() => {
if (debouncedSearchQuery) {
return {
title: 'No knowledge bases found',
description: 'Try a different search term',
}
}
return {
title: 'No knowledge bases yet',
description:
userPermissions.canEdit === true
? 'Create a knowledge base to get started'
: 'Knowledge bases will appear here once created',
}
}, [debouncedSearchQuery, userPermissions.canEdit])
return (
<>
<div className='flex h-screen flex-col pl-64'>
{/* Header */}
<KnowledgeHeader breadcrumbs={breadcrumbs} />
<div className='flex h-full flex-1 flex-col'>
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Main Content */}
<div className='flex-1 overflow-auto'>
<div className='px-6 pb-6'>
{/* Search and Create Section */}
<div className='mb-4 flex items-center justify-between pt-1'>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder='Search knowledge bases...'
/>
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[28px] pb-[24px]'>
<div>
<div className='flex items-start gap-[12px]'>
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#1E5A3E] bg-[#0F3D2C]'>
<Database className='h-[14px] w-[14px] text-[#34D399]' />
</div>
<h1 className='font-medium text-[18px]'>Knowledge Base</h1>
</div>
<p className='mt-[10px] text-[14px] text-[var(--text-tertiary)]'>
Create and manage knowledge bases with custom files.
</p>
</div>
<div className='flex items-center gap-2'>
{/* Sort Dropdown */}
<Popover open={isSortPopoverOpen} onOpenChange={setIsSortPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='outline' className={filterButtonClass}>
{currentSortLabel}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={4}>
<div className='mt-[14px] flex items-center justify-between'>
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-5)] px-[8px]'>
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
<Input
placeholder='Search'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='flex items-center gap-[8px]'>
{knowledgeBases.length > 0 && (
<Popover open={isSortPopoverOpen} onOpenChange={setIsSortPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='default' className='h-[32px] rounded-[6px]'>
{currentSortLabel}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={4}>
<div className='flex flex-col gap-[2px]'>
{SORT_OPTIONS.map((option) => (
<PopoverItem
key={option.value}
active={currentSortValue === option.value}
showCheck
onClick={() => handleSortChange(option.value)}
>
{option.label}
</PopoverItem>
))}
</PopoverContent>
</Popover>
</div>
</PopoverContent>
</Popover>
)}
{/* Create Button */}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
onClick={() => setIsCreateModalOpen(true)}
disabled={userPermissions.canEdit !== true}
variant='primary'
className='flex items-center gap-1'
>
<Plus className='h-3.5 w-3.5' />
<span>Create</span>
</Button>
</Tooltip.Trigger>
{userPermissions.canEdit !== true && (
<Tooltip.Content>
Write permission required to create knowledge bases
</Tooltip.Content>
)}
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
onClick={() => setIsCreateModalOpen(true)}
disabled={userPermissions.canEdit !== true}
variant='primary'
className='h-[32px] rounded-[6px]'
>
Create
</Button>
</Tooltip.Trigger>
{userPermissions.canEdit !== true && (
<Tooltip.Content>
Write permission required to create knowledge bases
</Tooltip.Content>
)}
</Tooltip.Root>
</div>
</div>
<div className='mt-[24px] grid grid-cols-1 gap-x-[20px] gap-y-[40px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{isLoading ? (
<BaseCardSkeletonGrid count={8} />
) : filteredAndSortedKnowledgeBases.length === 0 ? (
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{emptyState.title}
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>
{emptyState.description}
</p>
</div>
</div>
{/* Error State */}
{error && (
<div className='mb-4 rounded-md border border-red-200 bg-red-50 p-4'>
<p className='text-red-800 text-sm'>Error loading knowledge bases: {error}</p>
<button
onClick={handleRetry}
className='mt-2 text-red-600 text-sm underline hover:text-red-800'
>
Try again
</button>
) : error ? (
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
Error loading knowledge bases
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>{error}</p>
</div>
)}
{/* Content Area */}
{isLoading ? (
<KnowledgeBaseCardSkeletonGrid count={8} />
) : (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{filteredAndSortedKnowledgeBases.length === 0 ? (
knowledgeBases.length === 0 ? (
<EmptyStateCard
title='Create your first knowledge base'
description={
userPermissions.canEdit === true
? 'Upload your documents to create a knowledge base for your agents.'
: 'Knowledge bases will appear here. Contact an admin to create knowledge bases.'
}
buttonText={
userPermissions.canEdit === true
? 'Create Knowledge Base'
: 'Contact Admin'
}
onClick={
userPermissions.canEdit === true
? () => setIsCreateModalOpen(true)
: () => {}
}
icon={<LibraryBig className='h-4 w-4 text-muted-foreground' />}
/>
) : (
<div className='col-span-full py-12 text-center'>
<p className='text-muted-foreground'>
No knowledge bases match your search.
</p>
</div>
)
) : (
filteredAndSortedKnowledgeBases.map((kb) => {
const displayData = formatKnowledgeBaseForDisplay(
kb as KnowledgeBaseWithDocCount
)
return (
<BaseOverview
key={kb.id}
id={displayData.id}
title={displayData.title}
docCount={displayData.docCount}
description={displayData.description}
createdAt={displayData.createdAt}
updatedAt={displayData.updatedAt}
/>
)
})
)}
</div>
)}
</div>
</div>
) : (
filteredAndSortedKnowledgeBases.map((kb) => {
const displayData = formatKnowledgeBaseForDisplay(kb as KnowledgeBaseWithDocCount)
return (
<BaseCard
key={kb.id}
id={displayData.id}
title={displayData.title}
docCount={displayData.docCount}
description={displayData.description}
createdAt={displayData.createdAt}
updatedAt={displayData.updatedAt}
/>
)
})
)}
</div>
</div>
</div>
</div>
{/* Create Modal */}
<CreateModal
<CreateBaseModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onKnowledgeBaseCreated={handleKnowledgeBaseCreated}

View File

@@ -0,0 +1,6 @@
/**
* Knowledge Base layout - applies sidebar padding for all knowledge routes.
*/
export default function KnowledgeLayout({ children }: { children: React.ReactNode }) {
return <div className='flex h-full flex-1 flex-col pl-60'>{children}</div>
}

View File

@@ -1,72 +0,0 @@
'use client'
import { ChevronDown } from 'lucide-react'
import { Button, Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import {
KnowledgeBaseCardSkeletonGrid,
KnowledgeHeader,
SearchInput,
} from '@/app/workspace/[workspaceId]/knowledge/components'
import {
filterButtonClass,
SORT_OPTIONS,
} from '@/app/workspace/[workspaceId]/knowledge/components/shared'
export default function KnowledgeLoading() {
const breadcrumbs = [{ id: 'knowledge', label: 'Knowledge' }]
const currentSortLabel = SORT_OPTIONS[0]?.label || 'Last Updated'
return (
<div className='flex h-screen flex-col pl-64'>
{/* Header */}
<KnowledgeHeader breadcrumbs={breadcrumbs} />
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Main Content */}
<div className='flex-1 overflow-auto'>
<div className='px-6 pb-6'>
{/* Search and Create Section */}
<div className='mb-4 flex items-center justify-between pt-1'>
<SearchInput
value=''
onChange={() => {}}
placeholder='Search knowledge bases...'
disabled
/>
<div className='flex items-center gap-2'>
{/* Sort Dropdown */}
<Popover open={false}>
<PopoverAnchor asChild>
<Button variant='outline' className={filterButtonClass} disabled>
{currentSortLabel}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</PopoverAnchor>
<PopoverContent align='end' side='bottom' sideOffset={4}>
{SORT_OPTIONS.map((option) => (
<PopoverItem key={option.value} disabled>
{option.label}
</PopoverItem>
))}
</PopoverContent>
</Popover>
{/* Create Button */}
<Button disabled variant='primary' className='flex items-center gap-1'>
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
<span>Create</span>
</Button>
</div>
</div>
{/* Content Area */}
<KnowledgeBaseCardSkeletonGrid count={8} />
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,5 +1 @@
import { Knowledge } from '@/app/workspace/[workspaceId]/knowledge/knowledge'
export default function KnowledgePage() {
return <Knowledge />
}
export { Knowledge as default } from './knowledge'

View File

@@ -1,5 +1,5 @@
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
import type { SortOption, SortOrder } from '../components/shared'
import type { SortOption, SortOrder } from '../components/constants'
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
docCount?: number

View File

@@ -5,7 +5,7 @@ import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SidebarNew } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new'
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
@@ -14,10 +14,10 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
<ProviderModelsLoader />
<GlobalCommandsProvider>
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<div className='flex min-h-screen w-full'>
<div className='flex h-screen w-full'>
<WorkspacePermissionsProvider>
<div className='shrink-0' suppressHydrationWarning>
<SidebarNew />
<Sidebar />
</div>
{children}
</WorkspacePermissionsProvider>

View File

@@ -0,0 +1,3 @@
export { LineChart, type LineChartMultiSeries, type LineChartPoint } from './line-chart'
export { StatusBar, type StatusBarSegment } from './status-bar'
export { type WorkflowExecutionItem, WorkflowsList } from './workflows-list'

View File

@@ -0,0 +1,2 @@
export type { LineChartMultiSeries, LineChartPoint } from './line-chart'
export { default, LineChart } from './line-chart'

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
import { cn } from '@/lib/core/utils/cn'
import { formatDate, formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
export interface LineChartPoint {
timestamp: string
@@ -28,6 +29,7 @@ export function LineChart({
series?: LineChartMultiSeries[]
}) {
const containerRef = useRef<HTMLDivElement | null>(null)
const uniqueId = useRef(`chart-${Math.random().toString(36).substring(2, 9)}`).current
const [containerWidth, setContainerWidth] = useState<number>(420)
const width = containerWidth
const height = 166
@@ -54,6 +56,7 @@ export function LineChart({
const [hoverSeriesId, setHoverSeriesId] = useState<string | null>(null)
const [activeSeriesId, setActiveSeriesId] = useState<string | null>(null)
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
const [resolvedColors, setResolvedColors] = useState<Record<string, string>>({})
useEffect(() => {
if (typeof window === 'undefined') return
@@ -65,10 +68,40 @@ export function LineChart({
return () => observer.disconnect()
}, [])
useEffect(() => {
if (typeof window === 'undefined') return
const resolveColor = (c: string): string => {
if (!c.startsWith('var(')) return c
const tempEl = document.createElement('div')
tempEl.style.color = c
document.body.appendChild(tempEl)
const computed = window.getComputedStyle(tempEl).color
document.body.removeChild(tempEl)
return computed
}
const colorMap: Record<string, string> = { base: resolveColor(color) }
const allSeriesToResolve = Array.isArray(series) && series.length > 0 ? series : []
for (const s of allSeriesToResolve) {
const id = s.id || s.label || ''
if (id) colorMap[id] = resolveColor(s.color)
}
setResolvedColors(colorMap)
}, [color, series])
const hasExternalWrapper = !label || label === ''
if (data.length === 0) {
return (
<div
className='flex items-center justify-center rounded-lg border bg-card p-4'
className={cn(
'flex items-center justify-center',
!hasExternalWrapper && 'rounded-lg border bg-card p-4'
)}
style={{ width, height }}
>
<p className='text-muted-foreground text-sm'>No data</p>
@@ -90,9 +123,19 @@ export function LineChart({
const unitSuffixPre = (unit || '').trim().toLowerCase()
let maxValue = Math.ceil(paddedMax)
let minValue = Math.floor(paddedMin)
if (unitSuffixPre === 'ms') {
maxValue = Math.max(1000, Math.ceil(paddedMax / 1000) * 1000)
if (unitSuffixPre === 'ms' || unitSuffixPre === 'latency') {
minValue = 0
if (paddedMax < 10) {
maxValue = Math.ceil(paddedMax)
} else if (paddedMax < 100) {
maxValue = Math.ceil(paddedMax / 10) * 10
} else if (paddedMax < 1000) {
maxValue = Math.ceil(paddedMax / 50) * 50
} else if (paddedMax < 10000) {
maxValue = Math.ceil(paddedMax / 500) * 500
} else {
maxValue = Math.ceil(paddedMax / 1000) * 1000
}
}
const valueRange = maxValue - minValue || 1
@@ -152,7 +195,7 @@ export function LineChart({
if (!timestamp) return ''
try {
const f = formatDate(timestamp)
return `${f.compactDate} · ${f.compactTime}`
return `${f.compactDate} ${f.compactTime}`
} catch (e) {
const d = new Date(timestamp)
if (Number.isNaN(d.getTime())) return ''
@@ -172,51 +215,58 @@ export function LineChart({
return (
<div
ref={containerRef}
className='w-full overflow-hidden rounded-[11px] border bg-card p-4 shadow-sm'
className={cn(
'w-full overflow-hidden',
!hasExternalWrapper && 'rounded-[11px] border bg-card p-4 shadow-sm'
)}
>
<div className='mb-3 flex items-center gap-3'>
<h4 className='font-medium text-foreground text-sm'>{label}</h4>
{allSeries.length > 1 && (
<div className='flex items-center gap-2'>
{scaledSeries.slice(1).map((s) => {
const isActive = activeSeriesId ? activeSeriesId === s.id : true
const isHovered = hoverSeriesId === s.id
const dimmed = activeSeriesId ? !isActive : false
return (
<button
key={`legend-${s.id}`}
type='button'
aria-pressed={activeSeriesId === s.id}
aria-label={`Toggle ${s.label}`}
className='inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px]'
style={{
color: s.color,
opacity: dimmed ? 0.4 : isHovered ? 1 : 0.9,
border: '1px solid hsl(var(--border))',
background: 'transparent',
}}
onMouseEnter={() => setHoverSeriesId(s.id || null)}
onMouseLeave={() => setHoverSeriesId((prev) => (prev === s.id ? null : prev))}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
{!hasExternalWrapper && (
<div className='mb-3 flex items-center gap-3'>
<h4 className='font-medium text-foreground text-sm'>{label}</h4>
{allSeries.length > 1 && (
<div className='flex items-center gap-2'>
{scaledSeries.slice(1).map((s) => {
const isActive = activeSeriesId ? activeSeriesId === s.id : true
const isHovered = hoverSeriesId === s.id
const dimmed = activeSeriesId ? !isActive : false
return (
<button
key={`legend-${s.id}`}
type='button'
aria-pressed={activeSeriesId === s.id}
aria-label={`Toggle ${s.label}`}
className='inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px]'
style={{
color: resolvedColors[s.id || ''] || s.color,
opacity: dimmed ? 0.4 : isHovered ? 1 : 0.9,
border: '1px solid hsl(var(--border))',
background: 'transparent',
}}
onMouseEnter={() => setHoverSeriesId(s.id || null)}
onMouseLeave={() => setHoverSeriesId((prev) => (prev === s.id ? null : prev))}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))
}
}}
onClick={() =>
setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))
}
}}
onClick={() => setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))}
>
<span
aria-hidden='true'
className='inline-block h-[6px] w-[6px] rounded-full'
style={{ backgroundColor: s.color }}
/>
<span style={{ color: 'hsl(var(--muted-foreground))' }}>{s.label}</span>
</button>
)
})}
</div>
)}
</div>
>
<span
aria-hidden='true'
className='inline-block h-[6px] w-[6px] rounded-[2px]'
style={{ backgroundColor: resolvedColors[s.id || ''] || s.color }}
/>
<span style={{ color: 'hsl(var(--muted-foreground))' }}>{s.label}</span>
</button>
)
})}
</div>
)}
</div>
)}
<div className='relative' style={{ width, height }}>
<svg
width={width}
@@ -255,15 +305,23 @@ export function LineChart({
}}
>
<defs>
<linearGradient id={`area-${label.replace(/\s+/g, '-')}`} x1='0' x2='0' y1='0' y2='1'>
<stop offset='0%' stopColor={color} stopOpacity={isDark ? 0.25 : 0.45} />
<stop offset='100%' stopColor={color} stopOpacity={isDark ? 0.03 : 0.08} />
<linearGradient id={`area-${uniqueId}`} x1='0' x2='0' y1='0' y2='1'>
<stop
offset='0%'
stopColor={resolvedColors.base || color}
stopOpacity={isDark ? 0.25 : 0.45}
/>
<stop
offset='100%'
stopColor={resolvedColors.base || color}
stopOpacity={isDark ? 0.03 : 0.08}
/>
</linearGradient>
<clipPath id={`clip-${label.replace(/\s+/g, '-')}`}>
<clipPath id={`clip-${uniqueId}`}>
<rect
x={padding.left}
x={padding.left - 3}
y={yMin}
width={Math.max(1, chartWidth)}
width={Math.max(1, chartWidth + 6)}
height={chartHeight - (yMin - padding.top) * 2}
rx='2'
/>
@@ -281,7 +339,7 @@ export function LineChart({
{[0.25, 0.5, 0.75].map((p) => (
<line
key={`${label}-grid-${p}`}
key={`${uniqueId}-grid-${p}`}
x1={padding.left}
y1={padding.top + chartHeight * p}
x2={width - padding.right}
@@ -292,17 +350,32 @@ export function LineChart({
/>
))}
{/* axis baseline is drawn last (after line) to visually mask any overshoot */}
{!activeSeriesId && scaledPoints.length > 1 && (
<path
d={`${pathD} L ${scaledPoints[scaledPoints.length - 1].x} ${height - padding.bottom} L ${scaledPoints[0].x} ${height - padding.bottom} Z`}
fill={`url(#area-${label.replace(/\s+/g, '-')})`}
fill={`url(#area-${uniqueId})`}
stroke='none'
clipPath={`url(#clip-${label.replace(/\s+/g, '-')})`}
clipPath={`url(#clip-${uniqueId})`}
/>
)}
{!activeSeriesId &&
scaledPoints.length === 1 &&
(() => {
const strokeWidth = isDark ? 1.7 : 2.0
const capExtension = strokeWidth / 2
return (
<rect
x={padding.left - capExtension}
y={scaledPoints[0].y}
width={Math.max(1, chartWidth + capExtension * 2)}
height={height - padding.bottom - scaledPoints[0].y}
fill={`url(#area-${uniqueId})`}
clipPath={`url(#clip-${uniqueId})`}
/>
)
})()}
{orderedSeries.map((s, idx) => {
const isActive = activeSeriesId ? activeSeriesId === s.id : true
const isHovered = hoverSeriesId ? hoverSeriesId === s.id : false
@@ -321,14 +394,20 @@ export function LineChart({
}
})()
if (s.pts.length <= 1) {
const y = s.pts[0]?.y
if (y === undefined) return null
return (
<circle
<line
key={`pt-${idx}`}
cx={s.pts[0]?.x}
cy={s.pts[0]?.y}
r='3'
fill={s.color}
x1={padding.left}
y1={y}
x2={width - padding.right}
y2={y}
stroke={resolvedColors[s.id || ''] || s.color}
strokeWidth={sw}
strokeLinecap='round'
opacity={strokeOpacity}
strokeDasharray={s.dashed ? '5 4' : undefined}
/>
)
}
@@ -356,11 +435,11 @@ export function LineChart({
key={`series-${idx}`}
d={p}
fill='none'
stroke={s.color}
stroke={resolvedColors[s.id || ''] || s.color}
strokeWidth={sw}
strokeLinecap='round'
clipPath={`url(#clip-${label.replace(/\s+/g, '-')})`}
style={{ cursor: 'pointer', mixBlendMode: isDark ? 'screen' : 'normal' }}
clipPath={`url(#clip-${uniqueId})`}
style={{ mixBlendMode: isDark ? 'screen' : 'normal' }}
strokeDasharray={s.dashed ? '5 4' : undefined}
opacity={strokeOpacity}
onClick={() => setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))}
@@ -377,13 +456,13 @@ export function LineChart({
const active = guideSeries
const pt = active.pts[hoverIndex] || scaledPoints[hoverIndex]
return (
<g pointerEvents='none' clipPath={`url(#clip-${label.replace(/\s+/g, '-')})`}>
<g pointerEvents='none' clipPath={`url(#clip-${uniqueId})`}>
<line
x1={pt.x}
y1={padding.top}
x2={pt.x}
y2={height - padding.bottom}
stroke={active.color}
stroke={resolvedColors[active.id || ''] || active.color}
strokeOpacity='0.35'
strokeDasharray='3 3'
/>
@@ -392,7 +471,14 @@ export function LineChart({
const s = getSeriesById(activeSeriesId)
const spt = s?.pts?.[hoverIndex]
if (!s || !spt) return null
return <circle cx={spt.x} cy={spt.y} r='3' fill={s.color} />
return (
<circle
cx={spt.x}
cy={spt.y}
r='3'
fill={resolvedColors[s.id || ''] || s.color}
/>
)
})()}
</g>
)
@@ -439,12 +525,12 @@ export function LineChart({
const labelStr = Number.isNaN(ts.getTime()) ? '' : formatTick(ts)
return (
<text
key={`${label}-x-axis-${i}`}
key={`${uniqueId}-x-axis-${i}`}
x={x}
y={height - padding.bottom + 14}
fontSize='9'
textAnchor='middle'
fill='hsl(var(--muted-foreground))'
fill='var(--text-tertiary)'
>
{labelStr}
</text>
@@ -455,13 +541,19 @@ export function LineChart({
{(() => {
const unitSuffix = (unit || '').trim()
const showInTicks = unitSuffix === '%'
const fmtCompact = (v: number) =>
new Intl.NumberFormat('en-US', {
const isLatency = unitSuffix.toLowerCase() === 'latency'
const fmtCompact = (v: number) => {
if (isLatency) {
if (v === 0) return '0'
return formatLatency(v)
}
return new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
})
.format(v)
.toLowerCase()
}
return (
<>
<text
@@ -469,20 +561,20 @@ export function LineChart({
y={padding.top}
textAnchor='end'
fontSize='9'
fill='hsl(var(--muted-foreground))'
fill='var(--text-tertiary)'
>
{fmtCompact(maxValue)}
{showInTicks ? unit : ''}
{showInTicks && !isLatency ? unit : ''}
</text>
<text
x={padding.left - 8}
y={height - padding.bottom}
textAnchor='end'
fontSize='9'
fill='hsl(var(--muted-foreground))'
fill='var(--text-tertiary)'
>
{fmtCompact(minValue)}
{showInTicks ? unit : ''}
{showInTicks && !isLatency ? unit : ''}
</text>
</>
)
@@ -498,8 +590,6 @@ export function LineChart({
/>
</svg>
{/* No end labels to keep the chart clean and avoid edge overlap */}
{hoverIndex !== null &&
scaledPoints[hoverIndex] &&
(() => {
@@ -516,6 +606,7 @@ export function LineChart({
if (typeof v !== 'number' || !Number.isFinite(v)) return '—'
const u = unit || ''
if (u.includes('%')) return `${v.toFixed(1)}%`
if (u.toLowerCase() === 'latency') return formatLatency(v)
if (u.toLowerCase().includes('ms')) return `${Math.round(v)}ms`
if (u.toLowerCase().includes('exec')) return `${Math.round(v)}`
return `${Math.round(v)}${u}`
@@ -546,25 +637,30 @@ export function LineChart({
const top = Math.min(Math.max(anchorY - 26, padding.top), height - padding.bottom - 18)
return (
<div
className='pointer-events-none absolute rounded-md bg-background/80 px-2 py-1 font-medium text-[11px] shadow-sm ring-1 ring-border backdrop-blur'
className='pointer-events-none absolute rounded-[8px] border border-[var(--border-strong)] bg-[var(--surface-1)] px-[8px] py-[6px] font-medium text-[11px] shadow-lg'
style={{ left, top }}
>
{currentHoverDate && (
<div className='mb-1 text-[10px] text-muted-foreground'>{currentHoverDate}</div>
<div className='mb-1 text-[10px] text-[var(--text-tertiary)]'>
{currentHoverDate}
</div>
)}
{toDisplay.map((s) => {
const seriesIndex = allSeries.findIndex((x) => x.id === s.id)
const val = allSeries[seriesIndex]?.data?.[hoverIndex]?.value
const seriesLabel = s.label || s.id
const showLabel =
seriesLabel && seriesLabel !== 'base' && seriesLabel.trim() !== ''
return (
<div key={`tt-${s.id}`} className='flex items-center gap-1'>
<div key={`tt-${s.id}`} className='flex items-center gap-[8px]'>
<span
className='inline-block h-[6px] w-[6px] rounded-full'
style={{ backgroundColor: s.color }}
className='inline-block h-[6px] w-[6px] rounded-[2px]'
style={{ backgroundColor: resolvedColors[s.id || ''] || s.color }}
/>
<span style={{ color: 'hsl(var(--muted-foreground))' }}>
{s.label || s.id}
</span>
<span>{fmt(val)}</span>
{showLabel && (
<span className='text-[var(--text-secondary)]'>{seriesLabel}</span>
)}
<span className='text-[var(--text-primary)]'>{fmt(val)}</span>
</div>
)
})}

View File

@@ -0,0 +1,2 @@
export type { StatusBarSegment } from './status-bar'
export { default, StatusBar } from './status-bar'

View File

@@ -57,21 +57,28 @@ export function StatusBar({
: false
let color: string
let hoverBrightness: string
if (!segment.hasExecutions) {
color = 'bg-gray-300/60 dark:bg-gray-500/40'
hoverBrightness = 'hover:brightness-200'
} else if (segment.successRate === 100) {
color = 'bg-emerald-400/90'
hoverBrightness = 'hover:brightness-110'
} else if (segment.successRate >= 95) {
color = 'bg-amber-400/90'
hoverBrightness = 'hover:brightness-110'
} else {
color = 'bg-red-400/90'
hoverBrightness = 'hover:brightness-110'
}
return (
<div
key={i}
className={`h-6 flex-1 rounded-[3px] ${color} cursor-pointer transition-[opacity,transform] hover:opacity-90 ${
isSelected ? 'relative z-10 ring-2 ring-primary ring-offset-1' : 'relative z-0'
className={`h-6 flex-1 rounded-[3px] ${color} ${hoverBrightness} cursor-pointer transition-all ${
isSelected
? 'relative z-10 scale-105 shadow-sm ring-1 ring-[var(--text-secondary)]'
: 'relative z-0'
}`}
aria-label={`Segment ${i + 1}`}
onMouseEnter={() => setHoverIndex(i)}
@@ -90,7 +97,7 @@ export function StatusBar({
{hoverIndex !== null && segments[hoverIndex] && (
<div
className={`-translate-x-1/2 pointer-events-none absolute z-20 w-max whitespace-nowrap rounded-md bg-background/90 px-2 py-1 text-center text-[11px] shadow-sm ring-1 ring-border backdrop-blur ${
className={`-translate-x-1/2 pointer-events-none absolute z-20 w-max whitespace-nowrap rounded-[8px] border border-[var(--border-strong)] bg-[var(--surface-1)] px-[8px] py-[6px] text-center text-[11px] shadow-lg ${
preferBelow ? '' : '-translate-y-full'
}`}
style={{
@@ -101,14 +108,18 @@ export function StatusBar({
>
{segments[hoverIndex].hasExecutions ? (
<div>
<div className='font-semibold'>{labels[hoverIndex].successLabel}</div>
<div className='text-muted-foreground'>{labels[hoverIndex].countsLabel}</div>
<div className='font-semibold text-[var(--text-primary)]'>
{labels[hoverIndex].successLabel}
</div>
<div className='text-[var(--text-secondary)]'>{labels[hoverIndex].countsLabel}</div>
{labels[hoverIndex].rangeLabel && (
<div className='mt-0.5 text-muted-foreground'>{labels[hoverIndex].rangeLabel}</div>
<div className='mt-0.5 text-[var(--text-tertiary)]'>
{labels[hoverIndex].rangeLabel}
</div>
)}
</div>
) : (
<div className='text-muted-foreground'>{labels[hoverIndex].rangeLabel}</div>
<div className='text-[var(--text-secondary)]'>{labels[hoverIndex].rangeLabel}</div>
)}
</div>
)}

View File

@@ -0,0 +1,2 @@
export type { WorkflowExecutionItem } from './workflows-list'
export { default, WorkflowsList } from './workflows-list'

View File

@@ -0,0 +1,115 @@
import { memo } from 'react'
import { cn } from '@/lib/core/utils/cn'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { StatusBar, type StatusBarSegment } from '..'
export interface WorkflowExecutionItem {
workflowId: string
workflowName: string
segments: StatusBarSegment[]
overallSuccessRate: number
}
export function WorkflowsList({
executions,
filteredExecutions,
expandedWorkflowId,
onToggleWorkflow,
selectedSegments,
onSegmentClick,
searchQuery,
segmentDurationMs,
}: {
executions: WorkflowExecutionItem[]
filteredExecutions: WorkflowExecutionItem[]
expandedWorkflowId: string | null
onToggleWorkflow: (workflowId: string) => void
selectedSegments: Record<string, number[]>
onSegmentClick: (
workflowId: string,
segmentIndex: number,
timestamp: string,
mode: 'single' | 'toggle' | 'range'
) => void
searchQuery: string
segmentDurationMs: number
}) {
const { workflows } = useWorkflowRegistry()
return (
<div className='flex h-full flex-col overflow-hidden rounded-[6px] bg-[var(--surface-1)]'>
{/* Table header */}
<div className='flex-shrink-0 rounded-t-[6px] bg-[var(--surface-3)] px-[24px] py-[10px]'>
<div className='flex items-center gap-[16px]'>
<span className='w-[160px] flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow
</span>
<span className='flex-1 font-medium text-[12px] text-[var(--text-tertiary)]'>Logs</span>
<span className='w-[100px] flex-shrink-0 pl-[16px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Success Rate
</span>
</div>
</div>
{/* Table body - scrollable */}
<div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden'>
{filteredExecutions.length === 0 ? (
<div className='flex items-center justify-center py-[32px]'>
<span className='text-[13px] text-[var(--text-secondary)]'>
{searchQuery ? `No workflows found matching "${searchQuery}"` : 'No workflows found'}
</span>
</div>
) : (
<div>
{filteredExecutions.map((workflow, idx) => {
const isSelected = expandedWorkflowId === workflow.workflowId
return (
<div
key={workflow.workflowId}
className={cn(
'flex h-[44px] cursor-pointer items-center gap-[16px] px-[24px] hover:bg-[var(--c-2A2A2A)]',
isSelected && 'bg-[var(--c-2A2A2A)]'
)}
onClick={() => onToggleWorkflow(workflow.workflowId)}
>
{/* Workflow name with color */}
<div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{
backgroundColor: workflows[workflow.workflowId]?.color || '#64748b',
}}
/>
<span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'>
{workflow.workflowName}
</span>
</div>
{/* Status bar - takes most of the space */}
<div className='flex-1'>
<StatusBar
segments={workflow.segments}
selectedSegmentIndices={selectedSegments[workflow.workflowId] || null}
onSegmentClick={onSegmentClick as any}
workflowId={workflow.workflowId}
segmentDurationMs={segmentDurationMs}
preferBelow={idx < 2}
/>
</div>
{/* Success rate */}
<span className='w-[100px] flex-shrink-0 pl-[16px] font-medium text-[12px] text-[var(--text-primary)]'>
{workflow.overallSuccessRate.toFixed(1)}%
</span>
</div>
)
})}
</div>
)}
</div>
</div>
)
}
export default memo(WorkflowsList)

View File

@@ -0,0 +1,887 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Skeleton } from '@/components/ui/skeleton'
import {
formatLatency,
mapToExecutionLog,
mapToExecutionLogAlt,
} from '@/app/workspace/[workspaceId]/logs/utils'
import {
useExecutionsMetrics,
useGlobalDashboardLogs,
useWorkflowDashboardLogs,
} from '@/hooks/queries/logs'
import { useFilterStore } from '@/stores/logs/filters/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { LineChart, WorkflowsList } from './components'
type TimeFilter = '30m' | '1h' | '6h' | '12h' | '24h' | '3d' | '7d' | '14d' | '30d'
interface WorkflowExecution {
workflowId: string
workflowName: string
segments: {
successRate: number
timestamp: string
hasExecutions: boolean
totalExecutions: number
successfulExecutions: number
avgDurationMs?: number
p50Ms?: number
p90Ms?: number
p99Ms?: number
}[]
overallSuccessRate: number
}
const DEFAULT_SEGMENTS = 72
const MIN_SEGMENT_PX = 10
/**
* Skeleton loader for a single graph card
*/
function GraphCardSkeleton({ title }: { title: string }) {
return (
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
{title}
</span>
<Skeleton className='h-[20px] w-[40px]' />
</div>
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'>
<div className='flex h-[166px] flex-col justify-end gap-[4px]'>
{/* Skeleton bars simulating chart */}
<div className='flex items-end gap-[2px]'>
{Array.from({ length: 24 }).map((_, i) => (
<Skeleton
key={i}
className='flex-1'
style={{
height: `${Math.random() * 80 + 20}%`,
}}
/>
))}
</div>
</div>
</div>
</div>
)
}
/**
* Skeleton loader for a workflow row in the workflows list
*/
function WorkflowRowSkeleton() {
return (
<div className='flex h-[44px] items-center gap-[16px] px-[24px]'>
{/* Workflow name with color */}
<div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'>
<Skeleton className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]' />
<Skeleton className='h-[16px] flex-1' />
</div>
{/* Status bar - takes most of the space */}
<div className='flex-1'>
<Skeleton className='h-[24px] w-full rounded-[4px]' />
</div>
{/* Success rate */}
<div className='w-[100px] flex-shrink-0 pl-[16px]'>
<Skeleton className='h-[16px] w-[50px]' />
</div>
</div>
)
}
/**
* Skeleton loader for the workflows list table
*/
function WorkflowsListSkeleton({ rowCount = 5 }: { rowCount?: number }) {
return (
<div className='flex h-full flex-col overflow-hidden rounded-[6px] bg-[var(--surface-1)]'>
{/* Table header */}
<div className='flex-shrink-0 rounded-t-[6px] bg-[var(--surface-3)] px-[24px] py-[10px]'>
<div className='flex items-center gap-[16px]'>
<span className='w-[160px] flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow
</span>
<span className='flex-1 font-medium text-[12px] text-[var(--text-tertiary)]'>Logs</span>
<span className='w-[100px] flex-shrink-0 pl-[16px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Success Rate
</span>
</div>
</div>
{/* Table body - scrollable */}
<div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden'>
{Array.from({ length: rowCount }).map((_, i) => (
<WorkflowRowSkeleton key={i} />
))}
</div>
</div>
)
}
/**
* Complete skeleton loader for the entire dashboard
*/
function DashboardSkeleton() {
return (
<div className='mt-[24px] flex min-h-0 flex-1 flex-col'>
{/* Graphs Section */}
<div className='mb-[16px] flex-shrink-0'>
<div className='grid grid-cols-1 gap-[16px] md:grid-cols-3'>
<GraphCardSkeleton title='Runs' />
<GraphCardSkeleton title='Errors' />
<GraphCardSkeleton title='Latency' />
</div>
</div>
{/* Workflows Table - takes remaining space */}
<div className='min-h-0 flex-1 overflow-hidden'>
<WorkflowsListSkeleton rowCount={14} />
</div>
</div>
)
}
interface DashboardProps {
isLive?: boolean
refreshTrigger?: number
onCustomTimeRangeChange?: (isCustom: boolean) => void
}
export default function Dashboard({
isLive = false,
refreshTrigger = 0,
onCustomTimeRangeChange,
}: DashboardProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const getTimeFilterFromRange = (range: string): TimeFilter => {
switch (range) {
case 'Past 30 minutes':
return '30m'
case 'Past hour':
return '1h'
case 'Past 6 hours':
return '6h'
case 'Past 12 hours':
return '12h'
case 'Past 24 hours':
return '24h'
case 'Past 3 days':
return '3d'
case 'Past 7 days':
return '7d'
case 'Past 14 days':
return '14d'
case 'Past 30 days':
return '30d'
default:
return '30d'
}
}
const [endTime, setEndTime] = useState<Date>(new Date())
const [expandedWorkflowId, setExpandedWorkflowId] = useState<string | null>(null)
const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({})
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
const [segmentCount, setSegmentCount] = useState<number>(DEFAULT_SEGMENTS)
const barsAreaRef = useRef<HTMLDivElement | null>(null)
const {
workflowIds,
folderIds,
triggers,
timeRange: sidebarTimeRange,
level,
searchQuery,
} = useFilterStore()
const { workflows } = useWorkflowRegistry()
const timeFilter = getTimeFilterFromRange(sidebarTimeRange)
const getStartTime = useCallback(() => {
const start = new Date(endTime)
switch (timeFilter) {
case '30m':
start.setMinutes(endTime.getMinutes() - 30)
break
case '1h':
start.setHours(endTime.getHours() - 1)
break
case '6h':
start.setHours(endTime.getHours() - 6)
break
case '12h':
start.setHours(endTime.getHours() - 12)
break
case '24h':
start.setHours(endTime.getHours() - 24)
break
case '3d':
start.setDate(endTime.getDate() - 3)
break
case '7d':
start.setDate(endTime.getDate() - 7)
break
case '14d':
start.setDate(endTime.getDate() - 14)
break
case '30d':
start.setDate(endTime.getDate() - 30)
break
default:
start.setHours(endTime.getHours() - 24)
}
return start
}, [endTime, timeFilter])
const metricsFilters = useMemo(
() => ({
workspaceId,
segments: segmentCount || DEFAULT_SEGMENTS,
startTime: getStartTime().toISOString(),
endTime: endTime.toISOString(),
workflowIds: workflowIds.length > 0 ? workflowIds : undefined,
folderIds: folderIds.length > 0 ? folderIds : undefined,
triggers: triggers.length > 0 ? triggers : undefined,
level: level !== 'all' ? level : undefined,
}),
[workspaceId, segmentCount, getStartTime, endTime, workflowIds, folderIds, triggers, level]
)
const logsFilters = useMemo(
() => ({
workspaceId,
startDate: getStartTime().toISOString(),
endDate: endTime.toISOString(),
workflowIds: workflowIds.length > 0 ? workflowIds : undefined,
folderIds: folderIds.length > 0 ? folderIds : undefined,
triggers: triggers.length > 0 ? triggers : undefined,
level: level !== 'all' ? level : undefined,
searchQuery: searchQuery.trim() || undefined,
limit: 50,
}),
[workspaceId, getStartTime, endTime, workflowIds, folderIds, triggers, level, searchQuery]
)
const metricsQuery = useExecutionsMetrics(metricsFilters, {
enabled: Boolean(workspaceId),
})
const globalLogsQuery = useGlobalDashboardLogs(logsFilters, {
enabled: Boolean(workspaceId),
})
const workflowLogsQuery = useWorkflowDashboardLogs(expandedWorkflowId ?? undefined, logsFilters, {
enabled: Boolean(workspaceId) && Boolean(expandedWorkflowId),
})
const executions = metricsQuery.data?.workflows ?? []
const aggregateSegments = metricsQuery.data?.aggregateSegments ?? []
const loading = metricsQuery.isLoading
const error = metricsQuery.error?.message ?? null
// Check if any filters are actually applied
const hasActiveFilters = useMemo(
() =>
level !== 'all' ||
workflowIds.length > 0 ||
folderIds.length > 0 ||
triggers.length > 0 ||
searchQuery.trim() !== '',
[level, workflowIds, folderIds, triggers, searchQuery]
)
// Filter workflows based on search query and whether they have any executions matching the filters
const filteredExecutions = useMemo(() => {
let filtered = executions
// Only filter out workflows with no executions if filters are active
if (hasActiveFilters) {
filtered = filtered.filter((workflow) => {
const hasExecutions = workflow.segments.some((seg) => seg.hasExecutions === true)
return hasExecutions
})
}
// Apply search query filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase().trim()
filtered = filtered.filter((workflow) => workflow.workflowName.toLowerCase().includes(query))
}
// Sort by creation date (newest first) to match sidebar ordering
filtered = filtered.sort((a, b) => {
const workflowA = workflows[a.workflowId]
const workflowB = workflows[b.workflowId]
if (!workflowA || !workflowB) return 0
return workflowB.createdAt.getTime() - workflowA.createdAt.getTime()
})
return filtered
}, [executions, searchQuery, hasActiveFilters, workflows])
const globalLogs = useMemo(() => {
if (!globalLogsQuery.data?.pages) return []
return globalLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLog)
}, [globalLogsQuery.data?.pages])
const workflowLogs = useMemo(() => {
if (!workflowLogsQuery.data?.pages) return []
return workflowLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLogAlt)
}, [workflowLogsQuery.data?.pages])
const globalDetails = useMemo(() => {
if (!aggregateSegments.length) return null
const hasSelection = Object.keys(selectedSegments).length > 0
const hasWorkflowFilter = expandedWorkflowId && expandedWorkflowId !== '__multi__'
// Stack filters: workflow filter + segment selection
const segmentsToUse = hasSelection
? (() => {
// Get all selected segment indices across all workflows
const allSelectedIndices = new Set<number>()
Object.values(selectedSegments).forEach((indices) => {
indices.forEach((idx) => allSelectedIndices.add(idx))
})
// For each selected index, aggregate data from workflows that have that segment selected
// If a workflow filter is active, only include that workflow's data
return Array.from(allSelectedIndices)
.sort((a, b) => a - b)
.map((idx) => {
let totalExecutions = 0
let successfulExecutions = 0
let weightedLatencySum = 0
let latencyCount = 0
const timestamp = aggregateSegments[idx]?.timestamp || ''
// Sum up data from workflows that have this segment selected
Object.entries(selectedSegments).forEach(([workflowId, indices]) => {
if (!indices.includes(idx)) return
// If workflow filter is active, skip other workflows
if (hasWorkflowFilter && workflowId !== expandedWorkflowId) return
const workflow = filteredExecutions.find((w) => w.workflowId === workflowId)
const segment = workflow?.segments[idx]
if (!segment) return
totalExecutions += segment.totalExecutions || 0
successfulExecutions += segment.successfulExecutions || 0
if (segment.avgDurationMs && segment.totalExecutions) {
weightedLatencySum += segment.avgDurationMs * segment.totalExecutions
latencyCount += segment.totalExecutions
}
})
return {
timestamp,
totalExecutions,
successfulExecutions,
avgDurationMs: latencyCount > 0 ? weightedLatencySum / latencyCount : 0,
}
})
})()
: hasWorkflowFilter
? (() => {
// Filter to show only the expanded workflow's data
const workflow = filteredExecutions.find((w) => w.workflowId === expandedWorkflowId)
if (!workflow) return aggregateSegments
return workflow.segments.map((segment) => ({
timestamp: segment.timestamp,
totalExecutions: segment.totalExecutions || 0,
successfulExecutions: segment.successfulExecutions || 0,
avgDurationMs: segment.avgDurationMs ?? 0,
}))
})()
: hasActiveFilters
? (() => {
// Always recalculate aggregate segments based on filtered workflows when filters are active
return aggregateSegments.map((aggSeg, idx) => {
let totalExecutions = 0
let successfulExecutions = 0
let weightedLatencySum = 0
let latencyCount = 0
filteredExecutions.forEach((workflow) => {
const segment = workflow.segments[idx]
if (!segment) return
totalExecutions += segment.totalExecutions || 0
successfulExecutions += segment.successfulExecutions || 0
if (segment.avgDurationMs && segment.totalExecutions) {
weightedLatencySum += segment.avgDurationMs * segment.totalExecutions
latencyCount += segment.totalExecutions
}
})
return {
timestamp: aggSeg.timestamp,
totalExecutions,
successfulExecutions,
avgDurationMs: latencyCount > 0 ? weightedLatencySum / latencyCount : 0,
}
})
})()
: aggregateSegments
const errorRates = segmentsToUse.map((s) => ({
timestamp: s.timestamp,
value: s.totalExecutions > 0 ? (1 - s.successfulExecutions / s.totalExecutions) * 100 : 0,
}))
const executionCounts = segmentsToUse.map((s) => ({
timestamp: s.timestamp,
value: s.totalExecutions,
}))
const failureCounts = segmentsToUse.map((s) => ({
timestamp: s.timestamp,
value: s.totalExecutions - s.successfulExecutions,
}))
const latencies = segmentsToUse.map((s) => ({
timestamp: s.timestamp,
value: s.avgDurationMs ?? 0,
}))
return {
errorRates,
durations: [],
executionCounts,
failureCounts,
latencies,
logs: globalLogs,
allLogs: globalLogs,
}
}, [
aggregateSegments,
globalLogs,
selectedSegments,
filteredExecutions,
expandedWorkflowId,
hasActiveFilters,
])
const workflowDetails = useMemo(() => {
if (!expandedWorkflowId || !workflowLogs.length) return {}
return {
[expandedWorkflowId]: {
errorRates: [],
durations: [],
executionCounts: [],
logs: workflowLogs,
allLogs: workflowLogs,
},
}
}, [expandedWorkflowId, workflowLogs])
const aggregate = useMemo(() => {
const hasSelection = Object.keys(selectedSegments).length > 0
const hasWorkflowFilter = expandedWorkflowId && expandedWorkflowId !== '__multi__'
let totalExecutions = 0
let successfulExecutions = 0
let activeWorkflows = 0
let weightedLatencySum = 0
let latencyExecutionCount = 0
// Apply workflow filter first if present, otherwise use filtered executions
const workflowsToProcess = hasWorkflowFilter
? filteredExecutions.filter((wf) => wf.workflowId === expandedWorkflowId)
: filteredExecutions
for (const wf of workflowsToProcess) {
const selectedIndices = hasSelection ? selectedSegments[wf.workflowId] : null
let workflowHasExecutions = false
wf.segments.forEach((seg, idx) => {
// If segment selection exists, only count selected segments
// Otherwise, count all segments
if (!selectedIndices || selectedIndices.includes(idx)) {
const execCount = seg.totalExecutions || 0
totalExecutions += execCount
successfulExecutions += seg.successfulExecutions || 0
if (
seg.avgDurationMs !== undefined &&
seg.avgDurationMs !== null &&
seg.avgDurationMs > 0 &&
execCount > 0
) {
weightedLatencySum += seg.avgDurationMs * execCount
latencyExecutionCount += execCount
}
if (seg.hasExecutions) workflowHasExecutions = true
}
})
if (workflowHasExecutions) activeWorkflows += 1
}
const failedExecutions = Math.max(totalExecutions - successfulExecutions, 0)
const successRate = totalExecutions > 0 ? (successfulExecutions / totalExecutions) * 100 : 100
const avgLatency = latencyExecutionCount > 0 ? weightedLatencySum / latencyExecutionCount : 0
return {
totalExecutions,
successfulExecutions,
failedExecutions,
activeWorkflows,
successRate,
avgLatency,
}
}, [filteredExecutions, selectedSegments, expandedWorkflowId])
const loadMoreLogs = useCallback(
(workflowId: string) => {
if (
workflowId === expandedWorkflowId &&
workflowLogsQuery.hasNextPage &&
!workflowLogsQuery.isFetchingNextPage
) {
workflowLogsQuery.fetchNextPage()
}
},
[expandedWorkflowId, workflowLogsQuery]
)
const loadMoreGlobalLogs = useCallback(() => {
if (globalLogsQuery.hasNextPage && !globalLogsQuery.isFetchingNextPage) {
globalLogsQuery.fetchNextPage()
}
}, [globalLogsQuery])
const toggleWorkflow = useCallback(
(workflowId: string) => {
if (expandedWorkflowId === workflowId) {
setExpandedWorkflowId(null)
setSelectedSegments({})
setLastAnchorIndices({})
} else {
setExpandedWorkflowId(workflowId)
setSelectedSegments({})
setLastAnchorIndices({})
}
},
[expandedWorkflowId]
)
const handleSegmentClick = useCallback(
(
workflowId: string,
segmentIndex: number,
_timestamp: string,
mode: 'single' | 'toggle' | 'range'
) => {
if (mode === 'toggle') {
setSelectedSegments((prev) => {
const currentSegments = prev[workflowId] || []
const exists = currentSegments.includes(segmentIndex)
const nextSegments = exists
? currentSegments.filter((i) => i !== segmentIndex)
: [...currentSegments, segmentIndex].sort((a, b) => a - b)
if (nextSegments.length === 0) {
const { [workflowId]: _, ...rest } = prev
if (Object.keys(rest).length === 0) {
setExpandedWorkflowId(null)
}
return rest
}
const newState = { ...prev, [workflowId]: nextSegments }
const selectedWorkflowIds = Object.keys(newState)
if (selectedWorkflowIds.length > 1) {
setExpandedWorkflowId('__multi__')
} else if (selectedWorkflowIds.length === 1) {
setExpandedWorkflowId(selectedWorkflowIds[0])
}
return newState
})
setLastAnchorIndices((prev) => ({ ...prev, [workflowId]: segmentIndex }))
} else if (mode === 'single') {
setSelectedSegments((prev) => {
const currentSegments = prev[workflowId] || []
const isOnlySelectedSegment =
currentSegments.length === 1 && currentSegments[0] === segmentIndex
const isOnlyWorkflowSelected = Object.keys(prev).length === 1 && prev[workflowId]
if (isOnlySelectedSegment && isOnlyWorkflowSelected) {
setExpandedWorkflowId(null)
setLastAnchorIndices({})
return {}
}
setExpandedWorkflowId(workflowId)
setLastAnchorIndices({ [workflowId]: segmentIndex })
return { [workflowId]: [segmentIndex] }
})
} else if (mode === 'range') {
if (expandedWorkflowId === workflowId) {
setSelectedSegments((prev) => {
const currentSegments = prev[workflowId] || []
const anchor = lastAnchorIndices[workflowId] ?? segmentIndex
const [start, end] =
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
const union = new Set([...currentSegments, ...range])
return { ...prev, [workflowId]: Array.from(union).sort((a, b) => a - b) }
})
} else {
setExpandedWorkflowId(workflowId)
setSelectedSegments({ [workflowId]: [segmentIndex] })
setLastAnchorIndices({ [workflowId]: segmentIndex })
}
}
},
[expandedWorkflowId, lastAnchorIndices]
)
// Update endTime when filters change to ensure consistent time ranges with logs view
useEffect(() => {
setEndTime(new Date())
setSelectedSegments({})
setLastAnchorIndices({})
}, [timeFilter, workflowIds, folderIds, triggers, level, searchQuery])
// Clear expanded workflow if it's no longer in filtered executions
useEffect(() => {
if (expandedWorkflowId && expandedWorkflowId !== '__multi__') {
const isStillVisible = filteredExecutions.some((wf) => wf.workflowId === expandedWorkflowId)
if (!isStillVisible) {
setExpandedWorkflowId(null)
setSelectedSegments({})
setLastAnchorIndices({})
}
} else if (expandedWorkflowId === '__multi__') {
// Check if any of the selected workflows are still visible
const selectedWorkflowIds = Object.keys(selectedSegments)
const stillVisibleIds = selectedWorkflowIds.filter((id) =>
filteredExecutions.some((wf) => wf.workflowId === id)
)
if (stillVisibleIds.length === 0) {
setExpandedWorkflowId(null)
setSelectedSegments({})
setLastAnchorIndices({})
} else if (stillVisibleIds.length !== selectedWorkflowIds.length) {
// Remove segments for workflows that are no longer visible
const updatedSegments: Record<string, number[]> = {}
stillVisibleIds.forEach((id) => {
if (selectedSegments[id]) {
updatedSegments[id] = selectedSegments[id]
}
})
setSelectedSegments(updatedSegments)
if (stillVisibleIds.length === 1) {
setExpandedWorkflowId(stillVisibleIds[0])
}
}
}
}, [filteredExecutions, expandedWorkflowId, selectedSegments])
// Notify parent when custom time range is active
useEffect(() => {
const hasCustomRange = Object.keys(selectedSegments).length > 0
onCustomTimeRangeChange?.(hasCustomRange)
}, [selectedSegments, onCustomTimeRangeChange])
useEffect(() => {
if (!barsAreaRef.current) return
const el = barsAreaRef.current
let debounceId: ReturnType<typeof setTimeout> | null = null
const ro = new ResizeObserver(([entry]) => {
const w = entry?.contentRect?.width || 720
const n = Math.max(36, Math.min(96, Math.floor(w / MIN_SEGMENT_PX)))
if (debounceId) clearTimeout(debounceId)
debounceId = setTimeout(() => {
setSegmentCount(n)
}, 150)
})
ro.observe(el)
const rect = el.getBoundingClientRect()
if (rect?.width) {
const n = Math.max(36, Math.min(96, Math.floor(rect.width / MIN_SEGMENT_PX)))
setSegmentCount(n)
}
return () => {
if (debounceId) clearTimeout(debounceId)
ro.disconnect()
}
}, [])
// Live mode: refresh endTime periodically
useEffect(() => {
if (!isLive) return
const interval = setInterval(() => {
setEndTime(new Date())
}, 5000)
return () => clearInterval(interval)
}, [isLive])
// Refresh when trigger changes
useEffect(() => {
if (refreshTrigger > 0) {
setEndTime(new Date())
}
}, [refreshTrigger])
if (loading) {
return <DashboardSkeleton />
}
if (error) {
return (
<div className='mt-[24px] flex flex-1 items-center justify-center'>
<div className='text-[var(--text-error)]'>
<p className='font-medium text-[13px]'>Error loading data</p>
<p className='text-[12px]'>{error}</p>
</div>
</div>
)
}
if (executions.length === 0) {
return (
<div className='mt-[24px] flex flex-1 items-center justify-center'>
<div className='text-center text-[var(--text-secondary)]'>
<p className='font-medium text-[13px]'>No execution history</p>
<p className='mt-[4px] text-[12px]'>Execute some workflows to see their history here</p>
</div>
</div>
)
}
return (
<div className='mt-[24px] flex min-h-0 flex-1 flex-col'>
{/* Graphs Section */}
<div className='mb-[16px] flex-shrink-0'>
<div className='grid grid-cols-1 gap-[16px] md:grid-cols-3'>
{/* Runs Graph */}
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
Runs
</span>
{globalDetails && globalDetails.executionCounts.length > 0 && (
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
{aggregate.totalExecutions}
</span>
)}
</div>
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'>
{globalDetails ? (
<LineChart
key={`runs-${expandedWorkflowId || 'all'}-${Object.keys(selectedSegments).length}-${filteredExecutions.length}`}
data={globalDetails.executionCounts}
label=''
color='var(--brand-tertiary)'
unit=''
/>
) : (
<div className='flex h-[166px] items-center justify-center'>
<Loader2 className='h-[16px] w-[16px] animate-spin text-[var(--text-secondary)]' />
</div>
)}
</div>
</div>
{/* Errors Graph */}
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
Errors
</span>
{globalDetails && globalDetails.failureCounts.length > 0 && (
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
{aggregate.failedExecutions}
</span>
)}
</div>
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'>
{globalDetails ? (
<LineChart
key={`errors-${expandedWorkflowId || 'all'}-${Object.keys(selectedSegments).length}-${filteredExecutions.length}`}
data={globalDetails.failureCounts}
label=''
color='var(--text-error)'
unit=''
/>
) : (
<div className='flex h-[166px] items-center justify-center'>
<Loader2 className='h-[16px] w-[16px] animate-spin text-[var(--text-secondary)]' />
</div>
)}
</div>
</div>
{/* Latency Graph */}
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
Latency
</span>
{globalDetails && globalDetails.latencies.length > 0 && (
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
{formatLatency(aggregate.avgLatency)}
</span>
)}
</div>
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'>
{globalDetails ? (
<LineChart
key={`latency-${expandedWorkflowId || 'all'}-${Object.keys(selectedSegments).length}-${filteredExecutions.length}`}
data={globalDetails.latencies}
label=''
color='var(--c-F59E0B)'
unit='latency'
/>
) : (
<div className='flex h-[166px] items-center justify-center'>
<Loader2 className='h-[16px] w-[16px] animate-spin text-[var(--text-secondary)]' />
</div>
)}
</div>
</div>
</div>
</div>
{/* Workflows Table - takes remaining space */}
<div className='min-h-0 flex-1 overflow-hidden' ref={barsAreaRef}>
<WorkflowsList
executions={executions as WorkflowExecution[]}
filteredExecutions={filteredExecutions as WorkflowExecution[]}
expandedWorkflowId={expandedWorkflowId}
onToggleWorkflow={toggleWorkflow}
selectedSegments={selectedSegments}
onSegmentClick={handleSegmentClick}
searchQuery={searchQuery}
segmentDurationMs={
(endTime.getTime() - getStartTime().getTime()) / Math.max(1, segmentCount)
}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { default, default as Dashboard } from './dashboard'

View File

@@ -1,38 +0,0 @@
export interface AggregateMetrics {
totalExecutions: number
successfulExecutions: number
failedExecutions: number
activeWorkflows: number
successRate: number
}
export function KPIs({ aggregate }: { aggregate: AggregateMetrics }) {
return (
<div className='mb-2 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4'>
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Total executions</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>
{aggregate.totalExecutions.toLocaleString()}
</div>
</div>
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Success rate</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>
{aggregate.successRate.toFixed(1)}%
</div>
</div>
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Failed executions</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>
{aggregate.failedExecutions.toLocaleString()}
</div>
</div>
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Active workflows</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>{aggregate.activeWorkflows}</div>
</div>
</div>
)
}
export default KPIs

View File

@@ -1,17 +0,0 @@
export const getTriggerColor = (trigger: string | null | undefined): string => {
if (!trigger) return '#9ca3af'
switch (trigger.toLowerCase()) {
case 'manual':
return '#9ca3af'
case 'schedule':
return '#10b981'
case 'webhook':
return '#f97316'
case 'chat':
return '#8b5cf6'
case 'api':
return '#3b82f6'
default:
return '#9ca3af'
}
}

View File

@@ -1,624 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ArrowUpRight, Info, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-python'
import 'prismjs/components/prism-json'
import { CopyButton } from '@/components/ui/copy-button'
import { cn } from '@/lib/core/utils/cn'
import LineChart, {
type LineChartPoint,
} from '@/app/workspace/[workspaceId]/logs/components/dashboard/line-chart'
import { getTriggerColor } from '@/app/workspace/[workspaceId]/logs/components/dashboard/utils'
import LogMarkdownRenderer from '@/app/workspace/[workspaceId]/logs/components/sidebar/components/markdown-renderer'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import '@/components/emcn/components/code/code.css'
export interface ExecutionLogItem {
id: string
executionId: string
startedAt: string
level: string
trigger: string
triggerUserId: string | null
triggerInputs: any
outputs: any
errorMessage: string | null
duration: number | null
cost: {
input: number
output: number
total: number
} | null
workflowName?: string
workflowColor?: string
hasPendingPause?: boolean
}
/**
* Tries to parse a string as JSON and prettify it
*/
const tryPrettifyJson = (content: string): { isJson: boolean; formatted: string } => {
try {
const trimmed = content.trim()
if (
!(trimmed.startsWith('{') || trimmed.startsWith('[')) ||
!(trimmed.endsWith('}') || trimmed.endsWith(']'))
) {
return { isJson: false, formatted: content }
}
const parsed = JSON.parse(trimmed)
const prettified = JSON.stringify(parsed, null, 2)
return { isJson: true, formatted: prettified }
} catch (_e) {
return { isJson: false, formatted: content }
}
}
export interface WorkflowDetailsData {
errorRates: LineChartPoint[]
durations?: LineChartPoint[]
durationP50?: LineChartPoint[]
durationP90?: LineChartPoint[]
durationP99?: LineChartPoint[]
executionCounts: LineChartPoint[]
logs: ExecutionLogItem[]
allLogs: ExecutionLogItem[]
}
export function WorkflowDetails({
workspaceId,
expandedWorkflowId,
workflowName,
overview,
details,
selectedSegmentIndex,
selectedSegment,
selectedSegmentTimeRange,
selectedWorkflowNames,
segmentDurationMs,
clearSegmentSelection,
formatCost,
onLoadMore,
hasMore,
isLoadingMore,
}: {
workspaceId: string
expandedWorkflowId: string
workflowName: string
overview: { total: number; success: number; failures: number; rate: number }
details: WorkflowDetailsData | undefined
selectedSegmentIndex: number[] | null
selectedSegment: { timestamp: string; totalExecutions: number } | null
selectedSegmentTimeRange?: { start: Date; end: Date } | null
selectedWorkflowNames?: string[]
segmentDurationMs?: number
clearSegmentSelection: () => void
formatCost: (n: number) => string
onLoadMore?: () => void
hasMore?: boolean
isLoadingMore?: boolean
}) {
const router = useRouter()
const { workflows } = useWorkflowRegistry()
// Check if any logs have pending status to show Resume column
const hasPendingExecutions = useMemo(() => {
return details?.logs?.some((log) => log.hasPendingPause === true) || false
}, [details])
const workflowColor = useMemo(
() => workflows[expandedWorkflowId]?.color || '#3972F6',
[workflows, expandedWorkflowId]
)
const [expandedRowId, setExpandedRowId] = useState<string | null>(null)
const listRef = useRef<HTMLDivElement | null>(null)
const loaderRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const rootEl = listRef.current
const sentinel = loaderRef.current
if (!rootEl || !sentinel || !onLoadMore || !hasMore) return
let ticking = false
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]
if (entry?.isIntersecting && hasMore && !ticking && !isLoadingMore) {
ticking = true
setTimeout(() => {
onLoadMore()
ticking = false
}, 50)
}
},
{ root: rootEl, threshold: 0.1, rootMargin: '200px 0px 0px 0px' }
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [onLoadMore, hasMore, isLoadingMore])
// Fallback: if IntersectionObserver fails (older browsers), use scroll position
useEffect(() => {
const el = listRef.current
if (!el || !onLoadMore || !hasMore) return
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = el
const pct = (scrollTop / Math.max(1, scrollHeight - clientHeight)) * 100
if (pct > 80 && !isLoadingMore) onLoadMore()
}
el.addEventListener('scroll', onScroll)
return () => el.removeEventListener('scroll', onScroll)
}, [onLoadMore, hasMore, isLoadingMore])
return (
<div className='mt-1 overflow-hidden rounded-[11px] border bg-card shadow-sm'>
<div className='border-b bg-muted/30 px-4 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
{expandedWorkflowId !== 'all' && expandedWorkflowId !== '__multi__' ? (
<button
onClick={() => router.push(`/workspace/${workspaceId}/w/${expandedWorkflowId}`)}
className='group inline-flex items-center gap-2 text-left transition-opacity hover:opacity-70'
>
<span
className='h-[14px] w-[14px] flex-shrink-0 rounded'
style={{ backgroundColor: workflowColor }}
/>
<span className='font-[480] text-sm tracking-tight dark:font-[560]'>
{workflowName}
</span>
</button>
) : (
<div className='inline-flex items-center gap-2'>
<span
className='h-[14px] w-[14px] flex-shrink-0 rounded'
style={{ backgroundColor: workflowColor }}
/>
<span className='font-[480] text-sm tracking-tight dark:font-[560]'>
{workflowName}
</span>
</div>
)}
{Array.isArray(selectedSegmentIndex) &&
selectedSegmentIndex.length > 0 &&
(selectedSegment || selectedSegmentTimeRange || expandedWorkflowId === '__multi__') &&
(() => {
let tsLabel = 'Selected segment'
if (selectedSegmentTimeRange) {
const start = selectedSegmentTimeRange.start
const end = selectedSegmentTimeRange.end
const startFormatted = start.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
const endFormatted = end.toLocaleString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
tsLabel = `${startFormatted} ${endFormatted}`
} else if (selectedSegment?.timestamp) {
const tsObj = new Date(selectedSegment.timestamp)
if (!Number.isNaN(tsObj.getTime())) {
tsLabel = tsObj.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
}
}
const isMultiWorkflow =
expandedWorkflowId === '__multi__' &&
selectedWorkflowNames &&
selectedWorkflowNames.length > 0
const workflowLabel = isMultiWorkflow
? selectedWorkflowNames.length <= 2
? selectedWorkflowNames.join(', ')
: `${selectedWorkflowNames.slice(0, 2).join(', ')} +${selectedWorkflowNames.length - 2}`
: null
return (
<div className='inline-flex h-7 items-center gap-1.5 rounded-md border bg-muted/50 px-2.5'>
{isMultiWorkflow && workflowLabel && (
<span className='font-medium text-[11px] text-muted-foreground'>
{workflowLabel}
</span>
)}
<span className='font-medium text-[11px] text-foreground'>
{tsLabel}
{selectedSegmentIndex.length > 1 && !isMultiWorkflow
? ` (+${selectedSegmentIndex.length - 1})`
: ''}
</span>
<button
onClick={(e) => {
e.stopPropagation()
clearSegmentSelection()
}}
className='ml-0.5 flex h-4 w-4 items-center justify-center rounded text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40'
aria-label='Clear filter'
>
×
</button>
</div>
)
})()}
</div>
<div className='flex items-center gap-2'>
<div className='inline-flex h-7 items-center gap-2 rounded border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Executions</span>
<span className='font-[500] text-sm leading-none'>{overview.total}</span>
</div>
<div className='inline-flex h-7 items-center gap-2 rounded border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Success</span>
<span className='font-[500] text-sm leading-none'>{overview.rate.toFixed(1)}%</span>
</div>
<div className='inline-flex h-7 items-center gap-2 rounded border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Failures</span>
<span className='font-[500] text-sm leading-none'>{overview.failures}</span>
</div>
</div>
</div>
</div>
<div className='p-4'>
{details ? (
<>
{(() => {
const hasDuration = Array.isArray(details.durations) && details.durations.length > 0
const gridCols = hasDuration
? 'md:grid-cols-2 xl:grid-cols-4'
: 'md:grid-cols-2 xl:grid-cols-3'
const gridGap = hasDuration ? 'gap-2 xl:gap-2.5' : 'gap-3'
return (
<div className={`mb-3 grid grid-cols-1 ${gridGap} ${gridCols}`}>
<LineChart
data={details.errorRates}
label='Error Rate'
color='var(--text-error)'
unit='%'
/>
{hasDuration && (
<LineChart
data={details.durations!}
label='Duration'
color='#3b82f6'
unit='ms'
series={
[
details.durationP50
? {
id: 'p50',
label: 'p50',
color: '#60A5FA',
data: details.durationP50,
dashed: true,
}
: undefined,
details.durationP90
? {
id: 'p90',
label: 'p90',
color: '#3B82F6',
data: details.durationP90,
}
: undefined,
details.durationP99
? {
id: 'p99',
label: 'p99',
color: '#1D4ED8',
data: details.durationP99,
}
: undefined,
].filter(Boolean) as any
}
/>
)}
<LineChart
data={details.executionCounts}
label='Executions'
color='#10b981'
unit='execs'
/>
{(() => {
const failures = details.errorRates.map((e, i) => ({
timestamp: e.timestamp,
value: ((e.value || 0) / 100) * (details.executionCounts[i]?.value || 0),
}))
return <LineChart data={failures} label='Failures' color='#f59e0b' unit='' />
})()}
</div>
)
})()}
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='w-full overflow-x-auto'>
<div>
<div className='border-b-0'>
<div
className={cn(
'grid min-w-[980px] gap-2 px-2 pb-3 md:gap-3 lg:min-w-0 lg:gap-4',
hasPendingExecutions
? 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px]'
: 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px]'
)}
>
<div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'>
Time
</div>
<div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'>
Status
</div>
<div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'>
Trigger
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Cost
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Workflow
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Output
</div>
<div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Duration
</div>
{hasPendingExecutions && (
<div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Resume
</div>
)}
</div>
</div>
</div>
</div>
<div ref={listRef} className='flex-1 overflow-auto' style={{ maxHeight: '400px' }}>
<div className='pb-4'>
{(() => {
const logsToDisplay = details.logs
if (logsToDisplay.length === 0) {
return (
<div className='flex h-full items-center justify-center py-8'>
<div className='flex items-center gap-2 text-muted-foreground'>
<Info className='h-5 w-5' />
<span className='text-sm'>
No executions found in this time segment
</span>
</div>
</div>
)
}
return logsToDisplay.map((log) => {
const logDate = log?.startedAt ? new Date(log.startedAt) : null
const formattedDate =
logDate && !Number.isNaN(logDate.getTime())
? formatDate(logDate.toISOString())
: ({ compactDate: '—', compactTime: '' } as any)
const outputsStr = log.outputs ? JSON.stringify(log.outputs) : '—'
const errorStr = log.errorMessage || ''
const isExpanded = expandedRowId === log.id
const baseLevel = (log.level || 'info').toLowerCase()
const isPending = log.hasPendingPause === true
const isError = baseLevel === 'error'
const statusLabel = isPending
? 'Pending'
: `${baseLevel.charAt(0).toUpperCase()}${baseLevel.slice(1)}`
return (
<div
key={log.id}
className={cn(
'cursor-pointer transition-all duration-200',
isExpanded ? 'bg-accent/30' : 'hover:bg-accent/20'
)}
onClick={() =>
setExpandedRowId((prev) => (prev === log.id ? null : log.id))
}
>
<div
className={cn(
'grid min-w-[980px] items-center gap-2 px-2 py-3 md:gap-3 lg:min-w-0 lg:gap-4',
hasPendingExecutions
? 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px]'
: 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px]'
)}
>
<div>
<div className='text-[13px]'>
<span className='font-sm text-muted-foreground'>
{formattedDate.compactDate}
</span>
<span
style={{ marginLeft: '8px' }}
className='hidden font-[400] sm:inline'
>
{formattedDate.compactTime}
</span>
</div>
</div>
<div>
{isError || !isPending ? (
<div
className={cn(
'flex h-[24px] w-[56px] items-center justify-start rounded-[6px] border pl-[9px]',
isError
? 'gap-[5px] border-[#883827] bg-[#491515]'
: 'gap-[8px] border-[#686868] bg-[#383838]'
)}
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{
backgroundColor: isError ? 'var(--text-error)' : '#B7B7B7',
}}
/>
<span
className='font-medium text-[11.5px]'
style={{ color: isError ? 'var(--text-error)' : '#B7B7B7' }}
>
{statusLabel}
</span>
</div>
) : (
<div className='inline-flex items-center bg-amber-300 px-[6px] py-[2px] font-[400] text-amber-900 text-xs dark:bg-amber-500/90 dark:text-black'>
{statusLabel}
</div>
)}
</div>
<div>
{log.trigger ? (
<div
className='inline-flex items-center rounded-[6px] px-[6px] py-[2px] font-[400] text-white text-xs lg:px-[8px]'
style={{ backgroundColor: getTriggerColor(log.trigger) }}
>
{log.trigger}
</div>
) : (
<div className='text-muted-foreground text-xs'></div>
)}
</div>
<div>
<div className='font-[400] text-muted-foreground text-xs'>
{log.cost && log.cost.total > 0 ? formatCost(log.cost.total) : '—'}
</div>
</div>
{/* Workflow cell */}
<div className='whitespace-nowrap'>
{log.workflowName ? (
<div className='inline-flex items-center gap-2'>
<span
className='h-3.5 w-3.5 flex-shrink-0 rounded'
style={{ backgroundColor: log.workflowColor || '#64748b' }}
/>
<span
className='max-w-[150px] truncate text-muted-foreground text-xs'
title={log.workflowName}
>
{log.workflowName}
</span>
</div>
) : (
<span className='text-muted-foreground text-xs'></span>
)}
</div>
{/* Output cell */}
<div className='min-w-0 truncate whitespace-nowrap pr-2 text-[13px] text-muted-foreground'>
{log.level === 'error' && errorStr ? (
<span className='font-medium text-red-500 dark:text-red-400'>
{errorStr}
</span>
) : outputsStr.length > 220 ? (
`${outputsStr.slice(0, 217)}`
) : (
outputsStr
)}
</div>
<div className='text-right'>
<div className='text-muted-foreground text-xs tabular-nums'>
{typeof log.duration === 'number' ? `${log.duration}ms` : '—'}
</div>
</div>
{hasPendingExecutions && (
<div className='flex justify-end'>
{isPending && log.executionId ? (
<Link
href={`/resume/${expandedWorkflowId}/${log.executionId}`}
className='inline-flex h-7 w-7 items-center justify-center border border-primary/60 border-dashed text-primary hover:bg-primary/10'
aria-label='Open resume console'
>
<ArrowUpRight className='h-4 w-4' />
</Link>
) : (
<span className='h-7 w-7' />
)}
</div>
)}
</div>
{isExpanded && (
<div className='px-2 pt-0 pb-4'>
<div className='group relative w-full rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
<CopyButton
text={log.level === 'error' && errorStr ? errorStr : outputsStr}
className='z-10 h-7 w-7'
/>
{(() => {
const content =
log.level === 'error' && errorStr ? errorStr : outputsStr
const { isJson, formatted } = tryPrettifyJson(content)
return isJson ? (
<div className='code-editor-theme'>
<pre
className='max-h-[300px] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
dangerouslySetInnerHTML={{
__html: highlight(formatted, languages.json, 'json'),
}}
/>
</div>
) : (
<div className='max-h-[300px] overflow-y-auto'>
<LogMarkdownRenderer content={formatted} />
</div>
)
})()}
</div>
</div>
)}
</div>
)
})
})()}
{/* Bottom loading / sentinel */}
{hasMore && details.logs.length > 0 && (
<div className='flex items-center justify-center py-3 text-muted-foreground'>
<div ref={loaderRef} className='flex items-center gap-2'>
{isLoadingMore ? (
<>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-sm'>Loading more</span>
</>
) : (
<span className='text-sm'>Scroll to load more</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
</>
) : (
<div className='flex items-center justify-center py-12'>
<Loader2 className='h-6 w-6 animate-spin text-muted-foreground' />
</div>
)}
</div>
</div>
)
}
export default WorkflowDetails

View File

@@ -1,137 +0,0 @@
import { memo, useMemo } from 'react'
import { ScrollArea } from '@/components/ui/scroll-area'
import StatusBar, {
type StatusBarSegment,
} from '@/app/workspace/[workspaceId]/logs/components/dashboard/status-bar'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export interface WorkflowExecutionItem {
workflowId: string
workflowName: string
segments: StatusBarSegment[]
overallSuccessRate: number
}
export function WorkflowsList({
executions,
filteredExecutions,
expandedWorkflowId,
onToggleWorkflow,
selectedSegments,
onSegmentClick,
searchQuery,
segmentDurationMs,
}: {
executions: WorkflowExecutionItem[]
filteredExecutions: WorkflowExecutionItem[]
expandedWorkflowId: string | null
onToggleWorkflow: (workflowId: string) => void
selectedSegments: Record<string, number[]>
onSegmentClick: (
workflowId: string,
segmentIndex: number,
timestamp: string,
mode: 'single' | 'toggle' | 'range'
) => void
searchQuery: string
segmentDurationMs: number
}) {
const { workflows } = useWorkflowRegistry()
const segmentsCount = filteredExecutions[0]?.segments?.length || 120
const durationLabel = useMemo(() => {
const segMs = Math.max(1, Math.floor(segmentDurationMs || 0))
const days = Math.round(segMs / (24 * 60 * 60 * 1000))
if (days >= 1) return `${days} day${days !== 1 ? 's' : ''}`
const hours = Math.round(segMs / (60 * 60 * 1000))
if (hours >= 1) return `${hours} hour${hours !== 1 ? 's' : ''}`
const mins = Math.max(1, Math.round(segMs / (60 * 1000)))
return `${mins} minute${mins !== 1 ? 's' : ''}`
}, [segmentDurationMs])
// Date axis above the status bars intentionally removed for a cleaner, denser layout
function DynamicLegend() {
return (
<p className='mt-0.5 text-[11px] text-muted-foreground'>
Each cell {durationLabel} of the selected range. Click a cell to filter details.
</p>
)
}
return (
<div
className='overflow-hidden rounded-[11px] border bg-card shadow-sm'
style={{ height: '380px', display: 'flex', flexDirection: 'column' }}
>
<div className='flex-shrink-0 border-b bg-muted/30 px-4 py-2'>
<div className='flex items-center justify-between'>
<div>
<h3 className='font-[480] text-sm'>Workflows</h3>
<DynamicLegend />
</div>
<span className='text-muted-foreground text-xs'>
{filteredExecutions.length} workflow
{filteredExecutions.length !== 1 ? 's' : ''}
{searchQuery && ` (filtered from ${executions.length})`}
</span>
</div>
</div>
{/* Axis removed */}
<ScrollArea className='min-h-0 flex-1 overflow-auto'>
<div className='space-y-1 p-3'>
{filteredExecutions.length === 0 ? (
<div className='py-8 text-center text-muted-foreground text-sm'>
No workflows found matching "{searchQuery}"
</div>
) : (
filteredExecutions.map((workflow, idx) => {
const isSelected = expandedWorkflowId === workflow.workflowId
return (
<div
key={workflow.workflowId}
className={`flex cursor-pointer items-center gap-4 px-2 py-1.5 transition-colors ${
isSelected ? 'bg-accent/40' : 'hover:bg-accent/20'
}`}
onClick={() => onToggleWorkflow(workflow.workflowId)}
>
<div className='w-52 min-w-0 flex-shrink-0'>
<div className='flex items-center gap-2'>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded'
style={{
backgroundColor: workflows[workflow.workflowId]?.color || '#64748b',
}}
/>
<h3 className='truncate font-[460] text-sm dark:font-medium'>
{workflow.workflowName}
</h3>
</div>
</div>
<div className='flex-1'>
<StatusBar
segments={workflow.segments}
selectedSegmentIndices={selectedSegments[workflow.workflowId] || null}
onSegmentClick={onSegmentClick as any}
workflowId={workflow.workflowId}
segmentDurationMs={segmentDurationMs}
preferBelow={idx < 2}
/>
</div>
<div className='w-16 flex-shrink-0 text-right'>
<span className='font-[460] text-muted-foreground text-sm'>
{workflow.overallSuccessRate.toFixed(1)}%
</span>
</div>
</div>
)
})
)}
</div>
</ScrollArea>
</div>
)
}
export default memo(WorkflowsList)

View File

@@ -1,20 +0,0 @@
export default function FilterSection({
title,
content,
}: {
title: string
content?: React.ReactNode
}) {
return (
<div className='space-y-1'>
<div className='font-medium text-muted-foreground text-xs'>{title}</div>
<div>
{content || (
<div className='text-muted-foreground text-sm'>
Filter options for {title} will go here
</div>
)}
</div>
</div>
)
}

View File

@@ -1,157 +0,0 @@
import { useMemo, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/emcn'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { createLogger } from '@/lib/logs/console/logger'
import {
commandListClass,
dropdownContentClass,
filterButtonClass,
folderDropdownListStyle,
} from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
import { useFolders } from '@/hooks/queries/folders'
import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store'
const logger = createLogger('LogsFolderFilter')
interface FolderOption {
id: string
name: string
color: string
path: string // For nested folders, show full path
}
export default function FolderFilter() {
const { folderIds, toggleFolderId, setFolderIds } = useFilterStore()
const { getFolderTree } = useFolderStore()
const params = useParams()
const workspaceId = params.workspaceId as string
const [search, setSearch] = useState('')
const { isLoading: foldersLoading } = useFolders(workspaceId)
const folderTree = workspaceId ? getFolderTree(workspaceId) : []
const folders: FolderOption[] = useMemo(() => {
const flattenFolders = (nodes: FolderTreeNode[], parentPath = ''): FolderOption[] => {
const result: FolderOption[] = []
for (const node of nodes) {
const currentPath = parentPath ? `${parentPath} / ${node.name}` : node.name
result.push({
id: node.id,
name: node.name,
color: node.color || '#6B7280',
path: currentPath,
})
if (node.children && node.children.length > 0) {
result.push(...flattenFolders(node.children, currentPath))
}
}
return result
}
return flattenFolders(folderTree)
}, [folderTree])
// Get display text for the dropdown button
const getSelectedFoldersText = () => {
if (folderIds.length === 0) return 'All folders'
if (folderIds.length === 1) {
const selected = folders.find((f) => f.id === folderIds[0])
return selected ? selected.name : 'All folders'
}
return `${folderIds.length} folders selected`
}
// Check if a folder is selected
const isFolderSelected = (folderId: string) => {
return folderIds.includes(folderId)
}
// Clear all selections
const clearSelections = () => {
setFolderIds([])
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' className={filterButtonClass}>
{foldersLoading ? 'Loading folders...' : getSelectedFoldersText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
avoidCollisions={false}
sideOffset={4}
className={dropdownContentClass}
>
<Command>
<CommandInput placeholder='Search folders...' onValueChange={(v) => setSearch(v)} />
<CommandList className={commandListClass} style={folderDropdownListStyle}>
<CommandEmpty>
{foldersLoading ? 'Loading folders...' : 'No folders found.'}
</CommandEmpty>
<CommandGroup>
<CommandItem
value='all-folders'
onSelect={() => {
clearSelections()
}}
className='cursor-pointer'
>
<span>All folders</span>
{folderIds.length === 0 && (
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
)}
</CommandItem>
{useMemo(() => {
const q = search.trim().toLowerCase()
const filtered = q
? folders.filter((f) => (f.path || f.name).toLowerCase().includes(q))
: folders
return filtered.map((folder) => (
<CommandItem
key={folder.id}
value={`${folder.path || folder.name}`}
onSelect={() => {
toggleFolderId(folder.id)
}}
className='cursor-pointer'
>
<div className='flex items-center'>
<span className='truncate' title={folder.path}>
{folder.path}
</span>
</div>
{isFolderSelected(folder.id) && (
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
)}
</CommandItem>
))
}, [folders, search, folderIds])}
</CommandGroup>
</CommandList>
</Command>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,6 +0,0 @@
export { default as FilterSection } from './filter-section'
export { default as FolderFilter } from './folder'
export { default as Level } from './level'
export { default as Timeline } from './timeline'
export { default as Trigger } from './trigger'
export { default as Workflow } from './workflow'

View File

@@ -1,74 +0,0 @@
import { Check, ChevronDown } from 'lucide-react'
import { Button } from '@/components/emcn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { LogLevel } from '@/stores/logs/filters/types'
export default function Level() {
const { level, setLevel } = useFilterStore()
const specificLevels: { value: LogLevel; label: string; color: string }[] = [
{ value: 'error', label: 'Error', color: 'bg-destructive/100' },
{ value: 'info', label: 'Info', color: 'bg-muted-foreground/100' },
]
const getDisplayLabel = () => {
if (level === 'all') return 'Any status'
const selected = specificLevels.find((l) => l.value === level)
return selected ? selected.label : 'Any status'
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
className='h-8 w-full justify-between border-[#E5E5E5] bg-[var(--white)] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
>
{getDisplayLabel()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
className='w-[180px] border-[#E5E5E5] bg-[var(--white)] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
>
<DropdownMenuItem
key='all'
onSelect={(e) => {
e.preventDefault()
setLevel('all')
}}
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>Any status</span>
{level === 'all' && <Check className='h-4 w-4 text-muted-foreground' />}
</DropdownMenuItem>
<DropdownMenuSeparator />
{specificLevels.map((levelItem) => (
<DropdownMenuItem
key={levelItem.value}
onSelect={(e) => {
e.preventDefault()
setLevel(levelItem.value)
}}
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='flex items-center'>
<div className={`mr-2 h-2 w-2 rounded-full ${levelItem.color}`} />
{levelItem.label}
</div>
{level === levelItem.value && <Check className='h-4 w-4 text-muted-foreground' />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,31 +0,0 @@
export const filterButtonClass =
'w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[var(--white)] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
export const dropdownContentClass =
'w-[200px] rounded-lg border-[#E5E5E5] bg-[var(--white)] p-0 shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
export const commandListClass = 'overflow-y-auto overflow-x-hidden'
export const workflowDropdownListStyle = {
maxHeight: '14rem',
overflowY: 'auto',
overflowX: 'hidden',
} as const
export const folderDropdownListStyle = {
maxHeight: '10rem',
overflowY: 'auto',
overflowX: 'hidden',
} as const
export const triggerDropdownListStyle = {
maxHeight: '7.5rem',
overflowY: 'auto',
overflowX: 'hidden',
} as const
export const timelineDropdownListStyle = {
maxHeight: '9rem',
overflowY: 'auto',
overflowX: 'hidden',
} as const

View File

@@ -1,80 +0,0 @@
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import {
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverScrollArea,
PopoverTrigger,
} from '@/components/emcn'
import { filterButtonClass } from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { TimeRange } from '@/stores/logs/filters/types'
type TimelineProps = {
variant?: 'default' | 'header'
}
export default function Timeline({ variant = 'default' }: TimelineProps = {}) {
const { timeRange, setTimeRange } = useFilterStore()
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const specificTimeRanges: TimeRange[] = [
'Past 30 minutes',
'Past hour',
'Past 6 hours',
'Past 12 hours',
'Past 24 hours',
'Past 3 days',
'Past 7 days',
'Past 14 days',
'Past 30 days',
]
const handleTimeRangeSelect = (range: TimeRange) => {
setTimeRange(range)
setIsPopoverOpen(false)
}
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='outline' className={filterButtonClass}>
{timeRange}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</PopoverTrigger>
<PopoverContent
align={variant === 'header' ? 'end' : 'start'}
side='bottom'
sideOffset={4}
maxHeight={144}
>
<PopoverScrollArea>
<PopoverItem
active={timeRange === 'All time'}
showCheck
onClick={() => handleTimeRangeSelect('All time')}
>
All time
</PopoverItem>
{/* Separator */}
<div className='my-[2px] h-px bg-[var(--surface-11)]' />
{specificTimeRanges.map((range) => (
<PopoverItem
key={range}
active={timeRange === range}
showCheck
onClick={() => handleTimeRangeSelect(range)}
>
{range}
</PopoverItem>
))}
</PopoverScrollArea>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,113 +0,0 @@
import { useMemo, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { Button } from '@/components/emcn'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import {
commandListClass,
dropdownContentClass,
filterButtonClass,
triggerDropdownListStyle,
} from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { TriggerType } from '@/stores/logs/filters/types'
export default function Trigger() {
const { triggers, toggleTrigger, setTriggers } = useFilterStore()
const [search, setSearch] = useState('')
const triggerOptions = useMemo(() => getTriggerOptions(), [])
const getSelectedTriggersText = () => {
if (triggers.length === 0) return 'All triggers'
if (triggers.length === 1) {
const selected = triggerOptions.find((t) => t.value === triggers[0])
return selected ? selected.label : 'All triggers'
}
return `${triggers.length} triggers selected`
}
const isTriggerSelected = (trigger: TriggerType) => {
return triggers.includes(trigger)
}
const clearSelections = () => {
setTriggers([])
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' className={filterButtonClass}>
{getSelectedTriggersText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
avoidCollisions={false}
sideOffset={4}
className={dropdownContentClass}
>
<Command>
<CommandInput placeholder='Search triggers...' onValueChange={(v) => setSearch(v)} />
<CommandList className={commandListClass} style={triggerDropdownListStyle}>
<CommandEmpty>No triggers found.</CommandEmpty>
<CommandGroup>
<CommandItem
value='all-triggers'
onSelect={() => clearSelections()}
className='cursor-pointer'
>
<span>All triggers</span>
{triggers.length === 0 && (
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
)}
</CommandItem>
{useMemo(() => {
const q = search.trim().toLowerCase()
const filtered = q
? triggerOptions.filter((t) => t.label.toLowerCase().includes(q))
: triggerOptions
return filtered.map((triggerItem) => (
<CommandItem
key={triggerItem.value}
value={triggerItem.label}
onSelect={() => toggleTrigger(triggerItem.value)}
className='cursor-pointer'
>
<div className='flex items-center'>
{triggerItem.color && (
<div
className='mr-2 h-2 w-2 rounded-full'
style={{ backgroundColor: triggerItem.color }}
/>
)}
{triggerItem.label}
</div>
{isTriggerSelected(triggerItem.value) && (
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
)}
</CommandItem>
))
}, [search, triggers])}
</CommandGroup>
</CommandList>
</Command>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,155 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/emcn'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { createLogger } from '@/lib/logs/console/logger'
import {
commandListClass,
dropdownContentClass,
filterButtonClass,
workflowDropdownListStyle,
} from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
import { useFilterStore } from '@/stores/logs/filters/store'
const logger = createLogger('LogsWorkflowFilter')
interface WorkflowOption {
id: string
name: string
color: string
}
export default function Workflow() {
const { workflowIds, toggleWorkflowId, setWorkflowIds, folderIds } = useFilterStore()
const params = useParams()
const workspaceId = params?.workspaceId as string | undefined
const [workflows, setWorkflows] = useState<WorkflowOption[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
useEffect(() => {
const fetchWorkflows = async () => {
try {
setLoading(true)
const query = workspaceId ? `?workspaceId=${encodeURIComponent(workspaceId)}` : ''
const response = await fetch(`/api/workflows${query}`)
if (response.ok) {
const { data } = await response.json()
const scoped = Array.isArray(data)
? folderIds.length > 0
? data.filter((w: any) => (w.folderId ? folderIds.includes(w.folderId) : false))
: data
: []
const workflowOptions: WorkflowOption[] = scoped.map((workflow: any) => ({
id: workflow.id,
name: workflow.name,
color: workflow.color || '#3972F6',
}))
setWorkflows(workflowOptions)
}
} catch (error) {
logger.error('Failed to fetch workflows', { error })
} finally {
setLoading(false)
}
}
fetchWorkflows()
}, [workspaceId, folderIds])
const getSelectedWorkflowsText = () => {
if (workflowIds.length === 0) return 'All workflows'
if (workflowIds.length === 1) {
const selected = workflows.find((w) => w.id === workflowIds[0])
return selected ? selected.name : 'All workflows'
}
return `${workflowIds.length} workflows selected`
}
const isWorkflowSelected = (workflowId: string) => {
return workflowIds.includes(workflowId)
}
const clearSelections = () => {
setWorkflowIds([])
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' className={filterButtonClass}>
{loading ? 'Loading workflows...' : getSelectedWorkflowsText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
avoidCollisions={false}
sideOffset={4}
className={dropdownContentClass}
>
<Command>
<CommandInput placeholder='Search workflows...' onValueChange={(v) => setSearch(v)} />
<CommandList className={commandListClass} style={workflowDropdownListStyle}>
<CommandEmpty>{loading ? 'Loading workflows...' : 'No workflows found.'}</CommandEmpty>
<CommandGroup>
<CommandItem
value='all-workflows'
onSelect={() => {
clearSelections()
}}
className='cursor-pointer'
>
<span>All workflows</span>
{workflowIds.length === 0 && (
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
)}
</CommandItem>
{useMemo(() => {
const q = search.trim().toLowerCase()
const filtered = q
? workflows.filter((w) => w.name.toLowerCase().includes(q))
: workflows
return filtered.map((workflow) => (
<CommandItem
key={workflow.id}
value={`${workflow.name}`}
onSelect={() => {
toggleWorkflowId(workflow.id)
}}
className='cursor-pointer'
>
<div className='flex items-center'>
<div
className='mr-2 h-2 w-2 rounded-full'
style={{ backgroundColor: workflow.color }}
/>
{workflow.name}
</div>
{isWorkflowSelected(workflow.id) && (
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
)}
</CommandItem>
))
}, [workflows, search, workflowIds])}
</CommandGroup>
</CommandList>
</Command>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,77 +0,0 @@
'use client'
import { TimerOff } from 'lucide-react'
import { Button } from '@/components/emcn'
import { getSubscriptionStatus } from '@/lib/billing/client/utils'
import { isProd } from '@/lib/core/config/environment'
import {
FilterSection,
FolderFilter,
Level,
Timeline,
Trigger,
Workflow,
} from '@/app/workspace/[workspaceId]/logs/components/filters/components'
import { useSubscriptionData } from '@/hooks/queries/subscription'
/**
* Filters component for logs page - includes timeline and other filter options
*/
export function Filters() {
const { data: subscriptionData, isLoading } = useSubscriptionData()
const subscription = getSubscriptionStatus(subscriptionData?.data)
const isPaid = subscription.isPaid
const handleUpgradeClick = (e: React.MouseEvent) => {
e.preventDefault()
const event = new CustomEvent('open-settings', {
detail: { tab: 'subscription' },
})
window.dispatchEvent(event)
}
return (
<div className='h-full w-60 overflow-auto border-r p-4'>
{/* Show retention policy for free users in production only */}
{!isLoading && !isPaid && isProd && (
<div className='mb-4 overflow-hidden border border-border'>
<div className='flex items-center gap-2 border-b bg-background p-3'>
<TimerOff className='h-4 w-4 text-muted-foreground' />
<span className='font-medium text-sm'>Log Retention Policy</span>
</div>
<div className='p-3'>
<p className='text-muted-foreground text-xs'>
Logs are automatically deleted after 7 days.
</p>
<div className='mt-2.5'>
<Button
variant='default'
className='h-8 w-full px-3 text-xs'
onClick={handleUpgradeClick}
>
Upgrade Plan
</Button>
</div>
</div>
</div>
)}
<h2 className='mb-4 pl-2 font-medium text-sm'>Filters</h2>
{/* Level Filter */}
<FilterSection title='Level' content={<Level />} />
{/* Workflow Filter */}
<FilterSection title='Workflow' content={<Workflow />} />
{/* Folder Filter */}
<FilterSection title='Folder' content={<FolderFilter />} />
{/* Trigger Filter */}
<FilterSection title='Trigger' content={<Trigger />} />
{/* Timeline Filter */}
<FilterSection title='Timeline' content={<Timeline />} />
</div>
)
}

View File

@@ -1,105 +0,0 @@
'use client'
import { useState } from 'react'
import { Maximize2, Minimize2, X } from 'lucide-react'
import { Button } from '@/components/emcn'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { cn } from '@/lib/core/utils/cn'
import { FrozenCanvas } from '@/app/workspace/[workspaceId]/logs/components/frozen-canvas/frozen-canvas'
interface FrozenCanvasModalProps {
executionId: string
workflowName?: string
trigger?: string
traceSpans?: any[] // TraceSpans data from log metadata
isOpen: boolean
onClose: () => void
}
export function FrozenCanvasModal({
executionId,
workflowName,
trigger,
traceSpans,
isOpen,
onClose,
}: FrozenCanvasModalProps) {
const [isFullscreen, setIsFullscreen] = useState(false)
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen)
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className={cn(
'flex flex-col gap-0 p-0',
isFullscreen
? 'h-[100vh] max-h-[100vh] w-[100vw] max-w-[100vw]'
: 'h-[90vh] max-h-[90vh] overflow-hidden sm:max-w-[1100px]'
)}
hideCloseButton={true}
>
{/* Header */}
<DialogHeader className='flex flex-row items-center justify-between border-b bg-[var(--surface-1)] p-[16px] dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<div className='flex items-center gap-[12px]'>
<div>
<DialogTitle className='font-semibold text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Logged Workflow State
</DialogTitle>
<div className='mt-[4px] flex items-center gap-[8px]'>
{workflowName && (
<span className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
{workflowName}
</span>
)}
{trigger && (
<Badge variant='secondary' className='text-[12px]'>
{trigger}
</Badge>
)}
<span className='font-mono text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
{executionId.slice(0, 8)}...
</span>
</div>
</div>
</div>
<div className='flex items-center gap-[8px]'>
<Button variant='ghost' onClick={toggleFullscreen} className='h-[32px] w-[32px] p-0'>
{isFullscreen ? (
<Minimize2 className='h-[14px] w-[14px]' />
) : (
<Maximize2 className='h-[14px] w-[14px]' />
)}
</Button>
<Button variant='ghost' onClick={onClose} className='h-[32px] w-[32px] p-0'>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
</DialogHeader>
{/* Canvas Container */}
<div className='min-h-0 flex-1'>
<FrozenCanvas
executionId={executionId}
traceSpans={traceSpans}
height='100%'
width='100%'
// Ensure preview leaves padding at edges so nodes don't touch header
/>
</div>
{/* Footer with instructions */}
<div className='border-t bg-[var(--surface-1)] px-[24px] py-[12px] dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<div className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
Click on blocks to see their input and output data at execution time. This canvas shows
the exact state of the workflow when this execution was captured.
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,11 @@
export { Dashboard } from './dashboard'
export { LogDetails } from './log-details'
export { FileCards } from './log-details/components/file-download'
export { FrozenCanvas } from './log-details/components/frozen-canvas'
export { TraceSpans } from './log-details/components/trace-spans'
export {
AutocompleteSearch,
Controls,
LogsToolbar,
NotificationSettings,
} from './logs-toolbar'

View File

@@ -0,0 +1,264 @@
'use client'
import { useState } from 'react'
import { ArrowDown, Loader2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import { extractWorkspaceIdFromExecutionKey, getViewerUrl } from '@/lib/uploads/utils/file-utils'
const logger = createLogger('FileCards')
interface FileData {
id?: string
name: string
size: number
type: string
key: string
url: string
uploadedAt: string
expiresAt: string
storageProvider?: 's3' | 'blob' | 'local'
bucketName?: string
}
interface FileCardsProps {
files: FileData[]
isExecutionFile?: boolean
workspaceId?: string
}
interface FileCardProps {
file: FileData
isExecutionFile?: boolean
workspaceId?: string
}
/**
* Formats file size to human readable format
*/
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
}
/**
* Individual file card component
*/
function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) {
const [isDownloading, setIsDownloading] = useState(false)
const router = useRouter()
const handleDownload = () => {
if (isDownloading) return
setIsDownloading(true)
try {
logger.info(`Initiating download for file: ${file.name}`)
if (file.key.startsWith('url/')) {
if (file.url) {
window.open(file.url, '_blank')
logger.info(`Opened URL-type file directly: ${file.url}`)
return
}
throw new Error('URL is required for URL-type files')
}
let resolvedWorkspaceId = workspaceId
if (!resolvedWorkspaceId && isExecutionFile) {
resolvedWorkspaceId = extractWorkspaceIdFromExecutionKey(file.key) || undefined
} else if (!resolvedWorkspaceId) {
const segments = file.key.split('/')
if (segments.length >= 2 && /^[a-f0-9-]{36}$/.test(segments[0])) {
resolvedWorkspaceId = segments[0]
}
}
if (isExecutionFile) {
const serveUrl =
file.url || `/api/files/serve/${encodeURIComponent(file.key)}?context=execution`
window.open(serveUrl, '_blank')
logger.info(`Opened execution file serve URL: ${serveUrl}`)
} else {
const viewerUrl = resolvedWorkspaceId ? getViewerUrl(file.key, resolvedWorkspaceId) : null
if (viewerUrl) {
router.push(viewerUrl)
logger.info(`Navigated to viewer URL: ${viewerUrl}`)
} else {
logger.warn(
`Could not construct viewer URL for file: ${file.name}, falling back to serve URL`
)
const serveUrl =
file.url || `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
window.open(serveUrl, '_blank')
}
}
} catch (error) {
logger.error(`Failed to download file ${file.name}:`, error)
if (file.url) {
window.open(file.url, '_blank')
}
} finally {
setIsDownloading(false)
}
}
return (
<div className='flex flex-col gap-[8px] rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<span className='truncate font-medium text-[12px] text-[var(--text-secondary)]'>
{file.name}
</span>
</div>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatFileSize(file.size)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='font-medium text-[11px] text-[var(--text-subtle)]'>{file.type}</span>
<Button
variant='ghost'
className='!h-[20px] !px-[6px] !py-0 text-[11px]'
onClick={handleDownload}
disabled={isDownloading}
>
{isDownloading ? (
<Loader2 className='mr-[4px] h-[10px] w-[10px] animate-spin' />
) : (
<ArrowDown className='mr-[4px] h-[10px] w-[10px]' />
)}
{isDownloading ? 'Opening...' : 'Download'}
</Button>
</div>
</div>
)
}
/**
* Container component for displaying workflow execution files.
* Each file is displayed as a separate card with consistent styling.
*/
export function FileCards({ files, isExecutionFile = false, workspaceId }: FileCardsProps) {
if (!files || files.length === 0) {
return null
}
return (
<div className='flex w-full flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Files ({files.length})
</span>
<div className='flex flex-col gap-[8px]'>
{files.map((file, index) => (
<FileCard
key={file.id || `file-${index}`}
file={file}
isExecutionFile={isExecutionFile}
workspaceId={workspaceId}
/>
))}
</div>
</div>
)
}
/**
* Single file download button (legacy export for backwards compatibility)
*/
export function FileDownload({
file,
isExecutionFile = false,
className,
workspaceId,
}: {
file: FileData
isExecutionFile?: boolean
className?: string
workspaceId?: string
}) {
const [isDownloading, setIsDownloading] = useState(false)
const router = useRouter()
const handleDownload = () => {
if (isDownloading) return
setIsDownloading(true)
try {
logger.info(`Initiating download for file: ${file.name}`)
if (file.key.startsWith('url/')) {
if (file.url) {
window.open(file.url, '_blank')
logger.info(`Opened URL-type file directly: ${file.url}`)
return
}
throw new Error('URL is required for URL-type files')
}
let resolvedWorkspaceId = workspaceId
if (!resolvedWorkspaceId && isExecutionFile) {
resolvedWorkspaceId = extractWorkspaceIdFromExecutionKey(file.key) || undefined
} else if (!resolvedWorkspaceId) {
const segments = file.key.split('/')
if (segments.length >= 2 && /^[a-f0-9-]{36}$/.test(segments[0])) {
resolvedWorkspaceId = segments[0]
}
}
if (isExecutionFile) {
const serveUrl =
file.url || `/api/files/serve/${encodeURIComponent(file.key)}?context=execution`
window.open(serveUrl, '_blank')
logger.info(`Opened execution file serve URL: ${serveUrl}`)
} else {
const viewerUrl = resolvedWorkspaceId ? getViewerUrl(file.key, resolvedWorkspaceId) : null
if (viewerUrl) {
router.push(viewerUrl)
logger.info(`Navigated to viewer URL: ${viewerUrl}`)
} else {
logger.warn(
`Could not construct viewer URL for file: ${file.name}, falling back to serve URL`
)
const serveUrl =
file.url || `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
window.open(serveUrl, '_blank')
}
}
} catch (error) {
logger.error(`Failed to download file ${file.name}:`, error)
if (file.url) {
window.open(file.url, '_blank')
}
} finally {
setIsDownloading(false)
}
}
return (
<Button
variant='ghost'
className={`h-7 px-2 text-xs ${className}`}
onClick={handleDownload}
disabled={isDownloading}
>
{isDownloading ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<ArrowDown className='h-[14px] w-[14px]' />
)}
{isDownloading ? 'Downloading...' : 'Download'}
</Button>
)
}
export default FileCards

View File

@@ -0,0 +1 @@
export { default, FileCards, FileDownload } from './file-download'

View File

@@ -15,6 +15,7 @@ import {
X,
Zap,
} from 'lucide-react'
import { Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { redactApiKeys } from '@/lib/core/security/redaction'
@@ -35,61 +36,59 @@ function ExpandableDataSection({ title, data }: { title: string; data: any }) {
return (
<>
<div>
<div className='mb-[8px] flex items-center justify-between'>
<h4 className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{title}
</h4>
<div className='mb-[6px] flex items-center justify-between'>
<h4 className='font-medium text-[13px] text-[var(--text-primary)]'>{title}</h4>
<div className='flex items-center gap-[4px]'>
{isLargeData && (
<button
onClick={() => setIsModalOpen(true)}
className='p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
title='Expand in modal'
type='button'
>
<Maximize2 className='h-[12px] w-[12px]' />
<Maximize2 className='h-[14px] w-[14px]' />
</button>
)}
<button
onClick={() => setIsExpanded(!isExpanded)}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
type='button'
>
{isExpanded ? (
<ChevronUp className='h-[12px] w-[12px]' />
<ChevronUp className='h-[14px] w-[14px]' />
) : (
<ChevronDown className='h-[12px] w-[12px]' />
<ChevronDown className='h-[14px] w-[14px]' />
)}
</button>
</div>
</div>
<div
className={cn(
'overflow-y-auto bg-[var(--surface-5)] p-[12px] font-mono text-[12px] transition-all duration-200',
'overflow-y-auto rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] font-mono text-[12px] transition-all duration-200',
isExpanded ? 'max-h-96' : 'max-h-32'
)}
>
<pre className='whitespace-pre-wrap break-words text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<pre className='whitespace-pre-wrap break-words text-[var(--text-primary)]'>
{jsonString}
</pre>
</div>
</div>
{/* Modal for large data */}
{isModalOpen && (
<div className='fixed inset-0 z-[200] flex items-center justify-center bg-black/50'>
<div className='mx-[16px] h-[80vh] w-full max-w-4xl border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<div className='flex items-center justify-between border-b p-[16px] dark:border-[var(--border)]'>
<h3 className='font-medium text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{title}
</h3>
<div className='mx-[16px] flex h-[80vh] w-full max-w-4xl flex-col overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
<div className='flex items-center justify-between border-[var(--border)] border-b p-[16px]'>
<h3 className='font-medium text-[15px] text-[var(--text-primary)]'>{title}</h3>
<button
onClick={() => setIsModalOpen(false)}
className='p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
type='button'
>
<X className='h-[14px] w-[14px]' />
<X className='h-[16px] w-[16px]' />
</button>
</div>
<div className='h-[calc(80vh-4rem)] overflow-auto p-[16px]'>
<pre className='whitespace-pre-wrap break-words font-mono text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<div className='flex-1 overflow-auto p-[16px]'>
<pre className='whitespace-pre-wrap break-words font-mono text-[13px] text-[var(--text-primary)]'>
{jsonString}
</pre>
</div>
@@ -170,56 +169,45 @@ function PinnedLogs({
workflowState: any
onClose: () => void
}) {
// ALL HOOKS MUST BE CALLED BEFORE ANY CONDITIONAL RETURNS
const [currentIterationIndex, setCurrentIterationIndex] = useState(0)
// Reset iteration index when execution data changes
useEffect(() => {
setCurrentIterationIndex(0)
}, [executionData])
// Handle case where block has no execution data (e.g., failed workflow)
if (!executionData) {
const blockInfo = workflowState?.blocks?.[blockId]
const formatted = {
blockName: blockInfo?.name || 'Unknown Block',
blockType: blockInfo?.type || 'unknown',
status: 'not_executed',
duration: 'N/A',
input: null,
output: null,
errorMessage: null,
errorStackTrace: null,
cost: null,
tokens: null,
}
return (
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
<CardHeader className='pb-[12px]'>
<div className='flex items-center justify-between'>
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)]'>
<Zap className='h-[16px] w-[16px]' />
{formatted.blockName}
</CardTitle>
<button
onClick={onClose}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)]'
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
type='button'
>
<X className='h-[14px] w-[14px]' />
<X className='h-[16px] w-[16px]' />
</button>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<Badge variant='secondary'>{formatted.blockType}</Badge>
<Badge variant='outline'>not executed</Badge>
</div>
<div className='flex items-center gap-[8px]'>
<Badge variant='secondary'>{formatted.blockType}</Badge>
<Badge variant='outline'>not executed</Badge>
</div>
</CardHeader>
<CardContent className='space-y-[16px]'>
<div className='bg-[var(--surface-5)] p-[16px] text-center'>
<div className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
<div className='rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[16px] text-center'>
<div className='text-[13px] text-[var(--text-secondary)]'>
This block was not executed because the workflow failed before reaching it.
</div>
</div>
@@ -228,7 +216,6 @@ function PinnedLogs({
)
}
// Now we can safely use the execution data
const iterationInfo = getCurrentIterationData({
...executionData,
currentIteration: currentIterationIndex,
@@ -250,18 +237,19 @@ function PinnedLogs({
}
return (
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
<CardHeader className='pb-[12px]'>
<div className='flex items-center justify-between'>
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)]'>
<Zap className='h-[16px] w-[16px]' />
{formatted.blockName}
</CardTitle>
<button
onClick={onClose}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)]'
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
type='button'
>
<X className='h-[14px] w-[14px]' />
<X className='h-[16px] w-[16px]' />
</button>
</div>
<div className='flex items-center justify-between'>
@@ -272,17 +260,17 @@ function PinnedLogs({
<Badge variant='outline'>{formatted.status}</Badge>
</div>
{/* Iteration Navigation */}
{iterationInfo.hasMultipleIterations && (
<div className='flex items-center gap-[4px]'>
<button
onClick={goToPreviousIteration}
disabled={currentIterationIndex === 0}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50 dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50'
type='button'
>
<ChevronLeft className='h-[14px] w-[14px]' />
</button>
<span className='px-[8px] text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
<span className='px-[8px] text-[12px] text-[var(--text-tertiary)]'>
{iterationInfo.totalIterations !== undefined
? `${currentIterationIndex + 1} / ${iterationInfo.totalIterations}`
: `${currentIterationIndex + 1}`}
@@ -290,7 +278,8 @@ function PinnedLogs({
<button
onClick={goToNextIteration}
disabled={currentIterationIndex === totalIterations - 1}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50 dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50'
type='button'
>
<ChevronRight className='h-[14px] w-[14px]' />
</button>
@@ -300,18 +289,16 @@ function PinnedLogs({
</CardHeader>
<CardContent className='space-y-[16px]'>
<div className='grid grid-cols-2 gap-[16px]'>
<div className='grid grid-cols-2 gap-[12px]'>
<div className='flex items-center gap-[8px]'>
<Clock className='h-[14px] w-[14px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]' />
<span className='text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{formatted.duration}
</span>
<Clock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
<span className='text-[13px] text-[var(--text-primary)]'>{formatted.duration}</span>
</div>
{formatted.cost && formatted.cost.total > 0 && (
<div className='flex items-center gap-[8px]'>
<DollarSign className='h-[14px] w-[14px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]' />
<span className='text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<DollarSign className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
<span className='text-[13px] text-[var(--text-primary)]'>
${formatted.cost.total.toFixed(5)}
</span>
</div>
@@ -319,8 +306,8 @@ function PinnedLogs({
{formatted.tokens && formatted.tokens.total > 0 && (
<div className='flex items-center gap-[8px]'>
<Hash className='h-[14px] w-[14px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]' />
<span className='text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<Hash className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
<span className='text-[13px] text-[var(--text-primary)]'>
{formatted.tokens.total} tokens
</span>
</div>
@@ -333,19 +320,19 @@ function PinnedLogs({
{formatted.cost && formatted.cost.total > 0 && (
<div>
<h4 className='mb-[8px] font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<h4 className='mb-[6px] font-medium text-[13px] text-[var(--text-primary)]'>
Cost Breakdown
</h4>
<div className='space-y-[4px] text-[13px]'>
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<div className='space-y-[4px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] text-[13px]'>
<div className='flex justify-between text-[var(--text-primary)]'>
<span>Input:</span>
<span>${formatted.cost.input.toFixed(5)}</span>
</div>
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<div className='flex justify-between text-[var(--text-primary)]'>
<span>Output:</span>
<span>${formatted.cost.output.toFixed(5)}</span>
</div>
<div className='flex justify-between border-t pt-[4px] font-medium text-[var(--text-primary)] dark:border-[var(--border)] dark:text-[var(--text-primary)]'>
<div className='flex justify-between border-[var(--border)] border-t pt-[4px] font-medium text-[var(--text-primary)]'>
<span>Total:</span>
<span>${formatted.cost.total.toFixed(5)}</span>
</div>
@@ -355,19 +342,19 @@ function PinnedLogs({
{formatted.tokens && formatted.tokens.total > 0 && (
<div>
<h4 className='mb-[8px] font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<h4 className='mb-[6px] font-medium text-[13px] text-[var(--text-primary)]'>
Token Usage
</h4>
<div className='space-y-[4px] text-[13px]'>
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<div className='space-y-[4px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] text-[13px]'>
<div className='flex justify-between text-[var(--text-primary)]'>
<span>Prompt:</span>
<span>{formatted.tokens.prompt}</span>
</div>
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<div className='flex justify-between text-[var(--text-primary)]'>
<span>Completion:</span>
<span>{formatted.tokens.completion}</span>
</div>
<div className='flex justify-between border-t pt-[4px] font-medium text-[var(--text-primary)] dark:border-[var(--border)] dark:text-[var(--text-primary)]'>
<div className='flex justify-between border-[var(--border)] border-t pt-[4px] font-medium text-[var(--text-primary)]'>
<span>Total:</span>
<span>{formatted.tokens.total}</span>
</div>
@@ -404,6 +391,9 @@ interface FrozenCanvasProps {
className?: string
height?: string | number
width?: string | number
isModal?: boolean
isOpen?: boolean
onClose?: () => void
}
export function FrozenCanvas({
@@ -412,6 +402,9 @@ export function FrozenCanvas({
className,
height = '100%',
width = '100%',
isModal = false,
isOpen = false,
onClose,
}: FrozenCanvasProps) {
const [data, setData] = useState<FrozenCanvasData | null>(null)
const [blockExecutions, setBlockExecutions] = useState<Record<string, any>>({})
@@ -551,86 +544,115 @@ export function FrozenCanvas({
fetchData()
}, [executionId])
if (loading) {
return (
<div className={cn('flex items-center justify-center', className)} style={{ height, width }}>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading frozen canvas...</span>
const renderContent = () => {
if (loading) {
return (
<div
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading frozen canvas...</span>
</div>
</div>
</div>
)
}
if (error) {
return (
<div
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[8px] text-[var(--text-error)]'>
<AlertCircle className='h-[16px] w-[16px]' />
<span className='text-[13px]'>Failed to load frozen canvas: {error}</span>
</div>
</div>
)
}
if (!data) {
return (
<div
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
<div className='text-[13px] text-[var(--text-secondary)]'>No data available</div>
</div>
)
}
const isMigratedLog = (data.workflowState as any)?._migrated === true
if (isMigratedLog) {
return (
<div
className={cn('flex flex-col items-center justify-center gap-[16px] p-[32px]', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[12px] text-[var(--text-warning)]'>
<AlertCircle className='h-[20px] w-[20px]' />
<span className='font-medium text-[15px]'>Logged State Not Found</span>
</div>
<div className='max-w-md text-center text-[13px] text-[var(--text-secondary)]'>
This log was migrated from the old logging system. The workflow state at execution time
is not available.
</div>
<div className='text-[12px] text-[var(--text-tertiary)]'>
Note: {(data.workflowState as any)?._note}
</div>
</div>
)
}
return (
<>
<div
style={{ height, width }}
className={cn('frozen-canvas-mode h-full w-full', className)}
>
<WorkflowPreview
workflowState={data.workflowState}
showSubBlocks={true}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.8}
onNodeClick={(blockId) => {
setPinnedBlockId(blockId)
}}
/>
</div>
{pinnedBlockId && (
<PinnedLogs
executionData={blockExecutions[pinnedBlockId] || null}
blockId={pinnedBlockId}
workflowState={data.workflowState}
onClose={() => setPinnedBlockId(null)}
/>
)}
</>
)
}
if (error) {
if (isModal) {
return (
<div className={cn('flex items-center justify-center', className)} style={{ height, width }}>
<div className='flex items-center gap-[8px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
<AlertCircle className='h-[16px] w-[16px]' />
<span className='text-[13px]'>Failed to load frozen canvas: {error}</span>
</div>
</div>
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent size='xl' className='flex h-[90vh] flex-col'>
<ModalHeader>Workflow State</ModalHeader>
<ModalBody className='min-h-0 flex-1'>
<div className='flex h-full flex-col'>
<div className='min-h-0 flex-1 overflow-hidden rounded-[4px] border border-[var(--border)]'>
{renderContent()}
</div>
</div>
</ModalBody>
</ModalContent>
</Modal>
)
}
if (!data) {
return (
<div className={cn('flex items-center justify-center', className)} style={{ height, width }}>
<div className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
No data available
</div>
</div>
)
}
// Check if this is a migrated log without real workflow state
const isMigratedLog = (data.workflowState as any)?._migrated === true
if (isMigratedLog) {
return (
<div
className={cn('flex flex-col items-center justify-center gap-[16px] p-[32px]', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[12px] text-amber-600 dark:text-amber-400'>
<AlertCircle className='h-[20px] w-[20px]' />
<span className='font-medium text-[15px]'>Logged State Not Found</span>
</div>
<div className='max-w-md text-center text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
This log was migrated from the old logging system. The workflow state at execution time is
not available.
</div>
<div className='text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
Note: {(data.workflowState as any)?._note}
</div>
</div>
)
}
return (
<>
<div style={{ height, width }} className={cn('frozen-canvas-mode h-full w-full', className)}>
<WorkflowPreview
workflowState={data.workflowState}
showSubBlocks={true}
isPannable={true}
defaultZoom={0.8}
fitPadding={0.25}
onNodeClick={(blockId) => {
// Always allow clicking blocks, even if they don't have execution data
// This is important for failed workflows where some blocks never executed
setPinnedBlockId(blockId)
}}
/>
</div>
{pinnedBlockId && (
<PinnedLogs
executionData={blockExecutions[pinnedBlockId] || null}
blockId={pinnedBlockId}
workflowState={data.workflowState}
onClose={() => setPinnedBlockId(null)}
/>
)}
</>
)
return renderContent()
}

View File

@@ -0,0 +1 @@
export { FrozenCanvas } from './frozen-canvas'

View File

@@ -0,0 +1 @@
export { TraceSpans } from './trace-spans'

View File

@@ -0,0 +1,630 @@
'use client'
import type React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-json'
import clsx from 'clsx'
import { Button, ChevronDown } from '@/components/emcn'
import type { TraceSpan } from '@/stores/logs/filters/types'
import '@/components/emcn/components/code/code.css'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getBlock, getBlockByToolName } from '@/blocks'
interface TraceSpansProps {
traceSpans?: TraceSpan[]
totalDuration?: number
}
/**
* Generates a unique key for a trace span
*/
function getSpanKey(span: TraceSpan): string {
if (span.id) {
return span.id
}
const name = span.name || 'span'
const start = span.startTime || 'unknown-start'
const end = span.endTime || 'unknown-end'
return `${name}|${start}|${end}`
}
/**
* Merges multiple arrays of trace span children, deduplicating by span key
*/
function mergeTraceSpanChildren(...groups: TraceSpan[][]): TraceSpan[] {
const merged: TraceSpan[] = []
const seen = new Set<string>()
groups.forEach((group) => {
group.forEach((child) => {
const key = getSpanKey(child)
if (seen.has(key)) {
return
}
seen.add(key)
merged.push(child)
})
})
return merged
}
/**
* Normalizes a trace span by merging children from both the children array
* and any childTraceSpans in the output
*/
function normalizeChildWorkflowSpan(span: TraceSpan): TraceSpan {
const enrichedSpan: TraceSpan = { ...span }
if (enrichedSpan.output && typeof enrichedSpan.output === 'object') {
enrichedSpan.output = { ...enrichedSpan.output }
}
const normalizedChildren = Array.isArray(span.children)
? span.children.map((childSpan) => normalizeChildWorkflowSpan(childSpan))
: []
const outputChildSpans = Array.isArray(span.output?.childTraceSpans)
? (span.output!.childTraceSpans as TraceSpan[]).map((childSpan) =>
normalizeChildWorkflowSpan(childSpan)
)
: []
const mergedChildren = mergeTraceSpanChildren(normalizedChildren, outputChildSpans)
if (
enrichedSpan.output &&
typeof enrichedSpan.output === 'object' &&
enrichedSpan.output !== null &&
'childTraceSpans' in enrichedSpan.output
) {
const { childTraceSpans, ...cleanOutput } = enrichedSpan.output as {
childTraceSpans?: TraceSpan[]
} & Record<string, unknown>
enrichedSpan.output = cleanOutput
}
enrichedSpan.children = mergedChildren.length > 0 ? mergedChildren : undefined
return enrichedSpan
}
/**
* Formats duration in ms
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
/**
* Gets color for block type
*/
function getBlockColor(type: string): string {
switch (type.toLowerCase()) {
case 'agent':
return 'var(--brand-primary-hover-hex)'
case 'model':
return 'var(--brand-primary-hover-hex)'
case 'function':
return '#FF402F'
case 'tool':
return '#f97316'
case 'router':
return '#2FA1FF'
case 'condition':
return '#FF972F'
case 'evaluator':
return '#2FA1FF'
case 'api':
return '#2F55FF'
default:
return '#6b7280'
}
}
/**
* Gets icon and color for block type
*/
function getBlockIconAndColor(type: string): {
icon: React.ComponentType<{ className?: string }> | null
bgColor: string
} {
const lowerType = type.toLowerCase()
if (lowerType === 'loop') {
return { icon: LoopTool.icon, bgColor: LoopTool.bgColor }
}
if (lowerType === 'parallel') {
return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor }
}
const blockType = lowerType === 'model' ? 'agent' : lowerType
const blockConfig = getBlock(blockType)
if (blockConfig) {
return { icon: blockConfig.icon, bgColor: blockConfig.bgColor }
}
return { icon: null, bgColor: getBlockColor(type) }
}
/**
* Renders the progress bar showing execution timeline
*/
function ProgressBar({
span,
childSpans,
workflowStartTime,
totalDuration,
}: {
span: TraceSpan
childSpans?: TraceSpan[]
workflowStartTime: number
totalDuration: number
}) {
const segments = useMemo(() => {
if (!childSpans || childSpans.length === 0) {
const startMs = new Date(span.startTime).getTime()
const endMs = new Date(span.endTime).getTime()
const duration = endMs - startMs
const startPercent =
totalDuration > 0 ? ((startMs - workflowStartTime) / totalDuration) * 100 : 0
const widthPercent = totalDuration > 0 ? (duration / totalDuration) * 100 : 0
let color = getBlockColor(span.type)
if (span.type?.toLowerCase() === 'tool' && span.name) {
const toolBlock = getBlockByToolName(span.name)
if (toolBlock?.bgColor) {
color = toolBlock.bgColor
}
}
return [
{
startPercent: Math.max(0, Math.min(100, startPercent)),
widthPercent: Math.max(0.5, Math.min(100, widthPercent)),
color,
},
]
}
return childSpans.map((child) => {
const startMs = new Date(child.startTime).getTime()
const endMs = new Date(child.endTime).getTime()
const duration = endMs - startMs
const startPercent =
totalDuration > 0 ? ((startMs - workflowStartTime) / totalDuration) * 100 : 0
const widthPercent = totalDuration > 0 ? (duration / totalDuration) * 100 : 0
let color = getBlockColor(child.type)
if (child.type?.toLowerCase() === 'tool' && child.name) {
const toolBlock = getBlockByToolName(child.name)
if (toolBlock?.bgColor) {
color = toolBlock.bgColor
}
}
return {
startPercent: Math.max(0, Math.min(100, startPercent)),
widthPercent: Math.max(0.5, Math.min(100, widthPercent)),
color,
}
})
}, [span, childSpans, workflowStartTime, totalDuration])
return (
<div className='relative mb-[8px] h-[5px] w-full overflow-hidden rounded-[18px] bg-[var(--divider)]'>
{segments.map((segment, index) => (
<div
key={index}
className='absolute h-full'
style={{
left: `${segment.startPercent}%`,
width: `${segment.widthPercent}%`,
backgroundColor: segment.color,
}}
/>
))}
</div>
)
}
/**
* Renders input/output section with collapsible content
*/
function InputOutputSection({
label,
data,
isError,
spanId,
sectionType,
expandedSections,
onToggle,
}: {
label: string
data: unknown
isError: boolean
spanId: string
sectionType: 'input' | 'output'
expandedSections: Set<string>
onToggle: (section: string) => void
}) {
const sectionKey = `${spanId}-${sectionType}`
const isExpanded = expandedSections.has(sectionKey)
const jsonString = useMemo(() => {
if (!data) return ''
return JSON.stringify(data, null, 2)
}, [data])
const highlightedCode = useMemo(() => {
if (!jsonString) return ''
return highlight(jsonString, languages.json, 'json')
}, [jsonString])
return (
<div className='flex flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<span
className='font-medium text-[12px]'
style={{ color: isError ? 'var(--text-error)' : 'var(--text-tertiary)' }}
>
{label}
</span>
<Button
variant='ghost'
className='!h-[18px] !w-[18px] !p-0'
onClick={() => onToggle(sectionKey)}
>
<ChevronDown
className='h-[10px] w-[10px] text-[var(--text-subtle)] transition-transform'
style={{
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
</Button>
</div>
{isExpanded && (
<div>
{isError && typeof data === 'object' && data !== null && 'error' in data ? (
<div
className='rounded-[6px] px-[10px] py-[8px]'
style={{
backgroundColor: 'var(--terminal-status-error-bg)',
color: 'var(--text-error)',
}}
>
<div className='font-medium text-[12px]'>Error</div>
<div className='mt-[4px] text-[12px]'>{(data as { error: string }).error}</div>
</div>
) : (
<div className='code-editor-theme overflow-hidden rounded-[6px] bg-[var(--surface-3)] px-[10px] py-[8px]'>
<pre
className='m-0 w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
</div>
)}
</div>
)}
</div>
)
}
interface TraceSpanItemProps {
span: TraceSpan
totalDuration: number
workflowStartTime: number
onToggle: (spanId: string, expanded: boolean) => void
expandedSpans: Set<string>
isFirstSpan?: boolean
}
/**
* Individual trace span card component
*/
function TraceSpanItem({
span,
totalDuration,
workflowStartTime,
onToggle,
expandedSpans,
isFirstSpan = false,
}: TraceSpanItemProps): React.ReactNode {
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
const spanId = span.id || `span-${span.name}-${span.startTime}`
const spanStartTime = new Date(span.startTime).getTime()
const spanEndTime = new Date(span.endTime).getTime()
const duration = span.duration || spanEndTime - spanStartTime
const hasChildren = span.children && span.children.length > 0
const hasToolCalls = span.toolCalls && span.toolCalls.length > 0
const hasInput = Boolean(span.input)
const hasOutput = Boolean(span.output)
const isError = span.status === 'error'
const inlineChildTypes = new Set(['tool', 'model'])
const inlineChildren =
span.children?.filter((child) => inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
const otherChildren =
span.children?.filter((child) => !inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
const toolCallSpans = useMemo(() => {
if (!hasToolCalls) return []
return span.toolCalls!.map((toolCall, index) => {
const toolStartTime = toolCall.startTime
? new Date(toolCall.startTime).getTime()
: spanStartTime
const toolEndTime = toolCall.endTime
? new Date(toolCall.endTime).getTime()
: toolStartTime + (toolCall.duration || 0)
return {
id: `${spanId}-tool-${index}`,
name: toolCall.name,
type: 'tool',
duration: toolCall.duration || toolEndTime - toolStartTime,
startTime: new Date(toolStartTime).toISOString(),
endTime: new Date(toolEndTime).toISOString(),
status: toolCall.error ? ('error' as const) : ('success' as const),
input: toolCall.input,
output: toolCall.error
? { error: toolCall.error, ...(toolCall.output || {}) }
: toolCall.output,
} as TraceSpan
})
}, [hasToolCalls, span.toolCalls, spanId, spanStartTime])
const handleSectionToggle = (section: string) => {
setExpandedSections((prev) => {
const next = new Set(prev)
if (next.has(section)) {
next.delete(section)
} else {
next.add(section)
}
return next
})
}
const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type)
return (
<>
<div className='flex flex-col gap-[8px] rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
{!isFirstSpan && (
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: bgColor }}
>
{BlockIcon && <BlockIcon className={clsx('text-white', '!h-[9px] !w-[9px]')} />}
</div>
)}
<span
className='font-medium text-[12px]'
style={{ color: isError ? 'var(--text-error)' : 'var(--text-secondary)' }}
>
{span.name}
</span>
</div>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(duration)}
</span>
</div>
<ProgressBar
span={span}
childSpans={span.children}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
/>
{hasInput && (
<InputOutputSection
label='Input'
data={span.input}
isError={false}
spanId={spanId}
sectionType='input'
expandedSections={expandedSections}
onToggle={handleSectionToggle}
/>
)}
{hasInput && hasOutput && <div className='border-[var(--border)] border-t border-dashed' />}
{hasOutput && (
<InputOutputSection
label={isError ? 'Error' : 'Output'}
data={span.output}
isError={isError}
spanId={spanId}
sectionType='output'
expandedSections={expandedSections}
onToggle={handleSectionToggle}
/>
)}
{(hasToolCalls || inlineChildren.length > 0) &&
[...toolCallSpans, ...inlineChildren].map((childSpan, index) => {
const childId = childSpan.id || `${spanId}-inline-${index}`
const childIsError = childSpan.status === 'error'
const isInitialResponse = (childSpan.name || '')
.toLowerCase()
.includes('initial response')
const shouldRenderSeparator =
index === 0 && (hasInput || hasOutput) && !isInitialResponse
const toolBlock =
childSpan.type?.toLowerCase() === 'tool' && childSpan.name
? getBlockByToolName(childSpan.name)
: null
const { icon: ChildIcon, bgColor: childBgColor } = toolBlock
? { icon: toolBlock.icon, bgColor: toolBlock.bgColor }
: getBlockIconAndColor(childSpan.type)
return (
<div key={`inline-${childId}`}>
{shouldRenderSeparator && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
<div className='mt-[8px] flex flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: childBgColor }}
>
{ChildIcon && (
<ChildIcon className={clsx('text-white', '!h-[9px] !w-[9px]')} />
)}
</div>
<span
className='font-medium text-[12px]'
style={{
color: childIsError ? 'var(--text-error)' : 'var(--text-secondary)',
}}
>
{childSpan.name}
</span>
</div>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(childSpan.duration || 0)}
</span>
</div>
<ProgressBar
span={childSpan}
childSpans={undefined}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
/>
{childSpan.input && (
<InputOutputSection
label='Input'
data={childSpan.input}
isError={false}
spanId={`${childId}-input`}
sectionType='input'
expandedSections={expandedSections}
onToggle={handleSectionToggle}
/>
)}
{childSpan.input && childSpan.output && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{childSpan.output && (
<InputOutputSection
label={childIsError ? 'Error' : 'Output'}
data={childSpan.output}
isError={childIsError}
spanId={`${childId}-output`}
sectionType='output'
expandedSections={expandedSections}
onToggle={handleSectionToggle}
/>
)}
</div>
</div>
)
})}
</div>
{otherChildren.map((childSpan, index) => {
const enrichedChildSpan = normalizeChildWorkflowSpan(childSpan)
return (
<TraceSpanItem
key={index}
span={enrichedChildSpan}
totalDuration={totalDuration}
workflowStartTime={workflowStartTime}
onToggle={onToggle}
expandedSpans={expandedSpans}
isFirstSpan={false}
/>
)
})}
</>
)
}
/**
* Displays workflow execution trace spans with nested structure
*/
export function TraceSpans({ traceSpans, totalDuration = 0 }: TraceSpansProps) {
const [expandedSpans, setExpandedSpans] = useState<Set<string>>(new Set())
const workflowStartTime = useMemo(() => {
if (!traceSpans || traceSpans.length === 0) return 0
return traceSpans.reduce((earliest, span) => {
const startTime = new Date(span.startTime).getTime()
return startTime < earliest ? startTime : earliest
}, Number.POSITIVE_INFINITY)
}, [traceSpans])
const workflowEndTime = useMemo(() => {
if (!traceSpans || traceSpans.length === 0) return 0
return traceSpans.reduce((latest, span) => {
const endTime = span.endTime ? new Date(span.endTime).getTime() : 0
return endTime > latest ? endTime : latest
}, 0)
}, [traceSpans])
const actualTotalDuration = workflowEndTime - workflowStartTime
const handleSpanToggle = useCallback((spanId: string, expanded: boolean) => {
setExpandedSpans((prev) => {
const newExpandedSpans = new Set(prev)
if (expanded) {
newExpandedSpans.add(spanId)
} else {
newExpandedSpans.delete(spanId)
}
return newExpandedSpans
})
}, [])
const filtered = useMemo(() => {
const filterTree = (spans: TraceSpan[]): TraceSpan[] =>
spans
.map((s) => normalizeChildWorkflowSpan(s))
.map((s) => ({
...s,
children: s.children ? filterTree(s.children) : undefined,
}))
return traceSpans ? filterTree(traceSpans) : []
}, [traceSpans])
if (!traceSpans || traceSpans.length === 0) {
return <div className='text-[12px] text-[var(--text-secondary)]'>No trace data available</div>
}
return (
<div className='flex w-full flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>Trace Span</span>
<div className='flex flex-col gap-[8px]'>
{filtered.map((span, index) => (
<TraceSpanItem
key={index}
span={span}
totalDuration={actualTotalDuration !== undefined ? actualTotalDuration : totalDuration}
workflowStartTime={workflowStartTime}
onToggle={handleSpanToggle}
expandedSpans={expandedSpans}
isFirstSpan={index === 0}
/>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { LogDetails } from './log-details'

View File

@@ -0,0 +1,336 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronUp, X } from 'lucide-react'
import { Button, Eye } from '@/components/emcn'
import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { FileCards, FrozenCanvas, TraceSpans } from '@/app/workspace/[workspaceId]/logs/components'
import type { LogStatus } from '@/app/workspace/[workspaceId]/logs/utils'
import { formatDate, StatusBadge, TriggerBadge } from '@/app/workspace/[workspaceId]/logs/utils'
import { formatCost } from '@/providers/utils'
import type { WorkflowLog } from '@/stores/logs/filters/types'
interface LogDetailsProps {
/** The log to display details for */
log: WorkflowLog | null
/** Whether the sidebar is open */
isOpen: boolean
/** Callback when closing the sidebar */
onClose: () => void
/** Callback to navigate to next log */
onNavigateNext?: () => void
/** Callback to navigate to previous log */
onNavigatePrev?: () => void
/** Whether there is a next log available */
hasNext?: boolean
/** Whether there is a previous log available */
hasPrev?: boolean
}
/**
* Sidebar panel displaying detailed information about a selected log.
* Supports navigation between logs and expandable sections.
* @param props - Component props
* @returns Log details sidebar component
*/
export function LogDetails({
log,
isOpen,
onClose,
onNavigateNext,
onNavigatePrev,
hasNext = false,
hasPrev = false,
}: LogDetailsProps) {
const [isFrozenCanvasOpen, setIsFrozenCanvasOpen] = useState(false)
const scrollAreaRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = 0
}
}, [log?.id])
const isWorkflowExecutionLog = useMemo(() => {
if (!log) return false
return (
(log.trigger === 'manual' && !!log.duration) ||
(log.executionData?.enhanced && log.executionData?.traceSpans)
)
}, [log])
const hasCostInfo = useMemo(() => {
return isWorkflowExecutionLog && log?.cost
}, [log, isWorkflowExecutionLog])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose()
}
if (isOpen) {
if (e.key === 'ArrowUp' && hasPrev && onNavigatePrev) {
e.preventDefault()
handleNavigate(onNavigatePrev)
}
if (e.key === 'ArrowDown' && hasNext && onNavigateNext) {
e.preventDefault()
handleNavigate(onNavigateNext)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext])
const handleNavigate = (navigateFunction: () => void) => {
navigateFunction()
}
const formattedTimestamp = log ? formatDate(log.createdAt) : null
const logStatus: LogStatus = useMemo(() => {
if (!log) return 'info'
const baseLevel = (log.level || 'info').toLowerCase()
const isError = baseLevel === 'error'
const isPending = !isError && log.hasPendingPause === true
const isRunning = !isError && !isPending && log.duration === null
return isError ? 'error' : isPending ? 'pending' : isRunning ? 'running' : 'info'
}, [log])
return (
<div
className={`absolute top-[0px] right-0 bottom-0 z-50 w-[384px] transform overflow-hidden border-l bg-[var(--surface-1)] shadow-lg transition-transform duration-200 ease-out ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
aria-label='Log details sidebar'
>
{log && (
<div className='flex h-full flex-col px-[14px] pt-[12px]'>
{/* Header */}
<div className='flex items-center justify-between'>
<h2 className='font-medium text-[14px] text-[var(--text-primary)]'>Log Details</h2>
<div className='flex items-center gap-[1px]'>
<Button
variant='ghost'
className='!p-[4px]'
onClick={() => hasPrev && handleNavigate(onNavigatePrev!)}
disabled={!hasPrev}
aria-label='Previous log'
>
<ChevronUp className='h-[14px] w-[14px] rotate-180' />
</Button>
<Button
variant='ghost'
className='!p-[4px]'
onClick={() => hasNext && handleNavigate(onNavigateNext!)}
disabled={!hasNext}
aria-label='Next log'
>
<ChevronUp className='h-[14px] w-[14px]' />
</Button>
<Button variant='ghost' className='!p-[4px]' onClick={onClose} aria-label='Close'>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
</div>
{/* Content - Scrollable */}
<ScrollArea className='mt-[20px] h-full w-full overflow-y-auto' ref={scrollAreaRef}>
<div className='flex flex-col gap-[10px] pb-[16px]'>
{/* Timestamp & Workflow Row */}
<div className='flex items-center gap-[16px] px-[1px]'>
{/* Timestamp Card */}
<div className='flex w-[140px] flex-col gap-[8px]'>
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Timestamp
</div>
<div className='flex items-center gap-[6px]'>
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
{formattedTimestamp?.compactDate || 'N/A'}
</span>
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
{formattedTimestamp?.compactTime || 'N/A'}
</span>
</div>
</div>
{/* Workflow Card */}
{log.workflow && (
<div className='flex flex-col gap-[8px]'>
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow
</div>
<div className='flex items-center gap-[8px]'>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: log.workflow?.color }}
/>
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
{log.workflow.name}
</span>
</div>
</div>
)}
</div>
{/* Execution ID */}
{log.executionId && (
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Execution ID
</span>
<span className='truncate font-medium text-[14px] text-[var(--text-secondary)]'>
{log.executionId}
</span>
</div>
)}
{/* Details Section */}
<div className='flex flex-col'>
{/* Level */}
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>Level</span>
<StatusBadge status={logStatus} />
</div>
{/* Trigger */}
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Trigger
</span>
{log.trigger ? (
<TriggerBadge trigger={log.trigger} />
) : (
<span className='font-medium text-[12px] text-[var(--text-secondary)]'></span>
)}
</div>
{/* Duration */}
<div className='flex h-[48px] items-center justify-between p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Duration
</span>
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
{log.duration || '—'}
</span>
</div>
</div>
{/* Workflow State */}
{isWorkflowExecutionLog && log.executionId && (
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow State
</span>
<button
onClick={() => setIsFrozenCanvasOpen(true)}
className='flex items-center justify-between rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px] transition-colors hover:bg-[var(--c-2A2A2A)]'
>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
View Snapshot
</span>
<Eye className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
</button>
</div>
)}
{/* Workflow Execution - Trace Spans */}
{isWorkflowExecutionLog && log.executionData?.traceSpans && (
<TraceSpans
traceSpans={log.executionData.traceSpans}
totalDuration={log.executionData.totalDuration}
/>
)}
{/* Files */}
{log.files && log.files.length > 0 && <FileCards files={log.files} isExecutionFile />}
{/* Cost Breakdown */}
{hasCostInfo && (
<div className='flex flex-col gap-[8px]'>
<span className='px-[1px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Cost Breakdown
</span>
<div className='flex flex-col gap-[4px] rounded-[6px] border border-[var(--border)]'>
<div className='flex flex-col gap-[10px] rounded-[6px] p-[10px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Base Execution:
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{formatCost(BASE_EXECUTION_CHARGE)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Model Input:
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{formatCost(log.cost?.input || 0)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Model Output:
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{formatCost(log.cost?.output || 0)}
</span>
</div>
</div>
<div className='border-[var(--border)] border-t' />
<div className='flex flex-col gap-[10px] rounded-[6px] p-[10px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Total:
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{formatCost(log.cost?.total || 0)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Tokens:
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{log.cost?.tokens?.prompt || 0} in / {log.cost?.tokens?.completion || 0}{' '}
out
</span>
</div>
</div>
</div>
<div className='flex items-center justify-center rounded-[6px] bg-[var(--surface-2)] p-[8px] text-center'>
<p className='font-medium text-[11px] text-[var(--text-subtle)]'>
Total cost includes a base execution charge of{' '}
{formatCost(BASE_EXECUTION_CHARGE)} plus any model usage costs.
</p>
</div>
</div>
)}
</div>
</ScrollArea>
</div>
)}
{/* Frozen Canvas Modal */}
{log?.executionId && (
<FrozenCanvas
executionId={log.executionId}
traceSpans={log.executionData?.traceSpans}
isModal
isOpen={isFrozenCanvasOpen}
onClose={() => setIsFrozenCanvasOpen(false)}
/>
)}
</div>
)
}

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { ArrowUp, Bell, Loader2, RefreshCw, Search } from 'lucide-react'
import { type ReactNode, useState } from 'react'
import { ArrowUp, Bell, ChevronDown, Loader2, RefreshCw, Search } from 'lucide-react'
import {
Button,
Popover,
@@ -13,7 +13,83 @@ import { MoreHorizontal } from '@/components/emcn/icons'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/core/utils/cn'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import Timeline from '@/app/workspace/[workspaceId]/logs/components/filters/components/timeline'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { TimeRange } from '@/stores/logs/filters/types'
const FILTER_BUTTON_CLASS =
'w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[var(--white)] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
type TimelineProps = {
variant?: 'default' | 'header'
}
/**
* Timeline component for time range selection.
* Displays a dropdown with predefined time ranges.
* @param props - The component props
* @returns Time range selector dropdown
*/
function Timeline({ variant = 'default' }: TimelineProps = {}) {
const { timeRange, setTimeRange } = useFilterStore()
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const specificTimeRanges: TimeRange[] = [
'Past 30 minutes',
'Past hour',
'Past 6 hours',
'Past 12 hours',
'Past 24 hours',
'Past 3 days',
'Past 7 days',
'Past 14 days',
'Past 30 days',
]
const handleTimeRangeSelect = (range: TimeRange) => {
setTimeRange(range)
setIsPopoverOpen(false)
}
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='outline' className={FILTER_BUTTON_CLASS}>
{timeRange}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</PopoverTrigger>
<PopoverContent
align={variant === 'header' ? 'end' : 'start'}
side='bottom'
sideOffset={4}
maxHeight={144}
>
<PopoverScrollArea>
<PopoverItem
active={timeRange === 'All time'}
showCheck
onClick={() => handleTimeRangeSelect('All time')}
>
All time
</PopoverItem>
<div className='my-[2px] h-px bg-[var(--surface-11)]' />
{specificTimeRanges.map((range) => (
<PopoverItem
key={range}
active={timeRange === range}
showCheck
onClick={() => handleTimeRangeSelect(range)}
>
{range}
</PopoverItem>
))}
</PopoverScrollArea>
</PopoverContent>
</Popover>
)
}
interface ControlsProps {
searchQuery?: string

View File

@@ -0,0 +1 @@
export { Controls, default } from './controls'

View File

@@ -0,0 +1 @@
export { default, SlackChannelSelector } from './slack-channel-selector'

View File

@@ -81,8 +81,8 @@ export function SlackChannelSelector({
if (!accountId) {
return (
<div className='rounded-[8px] border border-dashed p-3 text-center'>
<p className='text-muted-foreground text-sm'>Select a Slack account first</p>
<div className='rounded-[6px] border bg-[var(--surface-3)] p-[10px] text-center'>
<p className='text-[12px] text-[var(--text-muted)]'>Select a Slack account first</p>
</div>
)
}
@@ -93,7 +93,7 @@ export function SlackChannelSelector({
}
return (
<div className='space-y-1'>
<div className='flex flex-col gap-[4px]'>
<Combobox
options={options}
value={value}
@@ -106,11 +106,13 @@ export function SlackChannelSelector({
error={fetchError}
/>
{selectedChannel && !fetchError && (
<p className='text-muted-foreground text-xs'>
<p className='text-[12px] text-[var(--text-muted)]'>
{selectedChannel.isPrivate ? 'Private' : 'Public'} channel: #{selectedChannel.name}
</p>
)}
{error && <p className='text-red-400 text-xs'>{error}</p>}
{error && <p className='text-[11px] text-[var(--text-error)]'>{error}</p>}
</div>
)
}
export default SlackChannelSelector

View File

@@ -0,0 +1 @@
export { default, WorkflowSelector } from './workflow-selector'

View File

@@ -1,9 +1,9 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Layers, X } from 'lucide-react'
import { Button, Combobox, type ComboboxOption } from '@/components/emcn'
import { Label, Skeleton } from '@/components/ui'
import { X } from 'lucide-react'
import { Badge, Combobox, type ComboboxOption } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
interface WorkflowSelectorProps {
workspaceId: string
@@ -13,10 +13,10 @@ interface WorkflowSelectorProps {
error?: string
}
const ALL_WORKFLOWS_VALUE = '__all_workflows__'
/**
* Multi-select workflow selector with "All Workflows" option.
* Uses Combobox's built-in showAllOption for the "All Workflows" selection.
* When allWorkflows is true, the array is empty and "All Workflows" is selected.
*/
export function WorkflowSelector({
workspaceId,
@@ -47,83 +47,48 @@ export function WorkflowSelector({
}, [workspaceId])
const options: ComboboxOption[] = useMemo(() => {
const workflowOptions = workflows.map((w) => ({
return workflows.map((w) => ({
label: w.name,
value: w.id,
}))
return [
{
label: 'All Workflows',
value: ALL_WORKFLOWS_VALUE,
icon: Layers,
},
...workflowOptions,
]
}, [workflows])
/**
* When allWorkflows is true, pass empty array so the "All" option is selected.
* Otherwise, pass the selected workflow IDs.
*/
const currentValues = useMemo(() => {
if (allWorkflows) {
return [ALL_WORKFLOWS_VALUE]
}
return selectedIds
return allWorkflows ? [] : selectedIds
}, [allWorkflows, selectedIds])
/**
* Handle multi-select changes from Combobox.
* Empty array from showAllOption = all workflows selected.
*/
const handleMultiSelectChange = (values: string[]) => {
const hasAllWorkflows = values.includes(ALL_WORKFLOWS_VALUE)
const hadAllWorkflows = allWorkflows
if (hasAllWorkflows && !hadAllWorkflows) {
// User selected "All Workflows" - clear individual selections
if (values.length === 0) {
onChange([], true)
} else if (!hasAllWorkflows && hadAllWorkflows) {
// User deselected "All Workflows" - switch to individual selection
onChange(
values.filter((v) => v !== ALL_WORKFLOWS_VALUE),
false
)
} else {
// Normal individual workflow selection/deselection
onChange(
values.filter((v) => v !== ALL_WORKFLOWS_VALUE),
false
)
onChange(values, false)
}
}
const handleRemove = (e: React.MouseEvent, id: string) => {
e.preventDefault()
e.stopPropagation()
if (id === ALL_WORKFLOWS_VALUE) {
onChange([], false)
} else {
onChange(
selectedIds.filter((i) => i !== id),
false
)
}
onChange(
selectedIds.filter((i) => i !== id),
false
)
}
const selectedWorkflows = useMemo(() => {
return workflows.filter((w) => selectedIds.includes(w.id))
}, [workflows, selectedIds])
// Render overlay content showing selected items as tags
const overlayContent = useMemo(() => {
if (allWorkflows) {
return (
<div className='flex items-center gap-1'>
<Button
variant='outline'
className='pointer-events-auto h-6 gap-1 rounded-[6px] px-2 text-[11px]'
onMouseDown={(e) => handleRemove(e, ALL_WORKFLOWS_VALUE)}
>
<Layers className='h-3 w-3' />
All Workflows
<X className='h-3 w-3' />
</Button>
</div>
)
return <span className='truncate text-[var(--text-primary)]'>All Workflows</span>
}
if (selectedWorkflows.length === 0) {
@@ -131,22 +96,22 @@ export function WorkflowSelector({
}
return (
<div className='flex items-center gap-1 overflow-hidden'>
<div className='flex items-center gap-[4px] overflow-hidden'>
{selectedWorkflows.slice(0, 2).map((w) => (
<Button
<Badge
key={w.id}
variant='outline'
className='pointer-events-auto h-6 gap-1 rounded-[6px] px-2 text-[11px]'
className='pointer-events-auto cursor-pointer gap-[4px] rounded-[6px] px-[8px] py-[2px] text-[11px]'
onMouseDown={(e) => handleRemove(e, w.id)}
>
{w.name}
<X className='h-3 w-3' />
</Button>
</Badge>
))}
{selectedWorkflows.length > 2 && (
<span className='flex h-6 items-center rounded-[6px] border px-2 text-[11px]'>
<Badge variant='outline' className='rounded-[6px] px-[8px] py-[2px] text-[11px]'>
+{selectedWorkflows.length - 2}
</span>
</Badge>
)}
</div>
)
@@ -154,16 +119,16 @@ export function WorkflowSelector({
if (isLoading) {
return (
<div className='space-y-2'>
<Label className='font-medium text-sm'>Workflows</Label>
<Skeleton className='h-9 w-full rounded-[4px]' />
<div className='flex flex-col gap-[4px]'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>Workflows</span>
<Skeleton className='h-[34px] w-full rounded-[6px]' />
</div>
)
}
return (
<div className='space-y-2'>
<Label className='font-medium text-sm'>Workflows</Label>
<div className='flex flex-col gap-[4px]'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>Workflows</span>
<Combobox
options={options}
multiSelect
@@ -174,10 +139,11 @@ export function WorkflowSelector({
overlayContent={overlayContent}
searchable
searchPlaceholder='Search workflows...'
showAllOption
allOptionLabel='All Workflows'
/>
<p className='text-muted-foreground text-xs'>
Select which workflows should trigger this notification
</p>
</div>
)
}
export default WorkflowSelector

View File

@@ -0,0 +1 @@
export { NotificationSettings } from './notifications'

View File

@@ -0,0 +1 @@
export { AutocompleteSearch } from './search'

View File

@@ -31,7 +31,7 @@ interface AutocompleteSearchProps {
export function AutocompleteSearch({
value,
onChange,
placeholder = 'Search logs...',
placeholder = 'Search',
className,
onOpenChange,
}: AutocompleteSearchProps) {
@@ -139,11 +139,11 @@ export function AutocompleteSearch({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const [dropdownWidth, setDropdownWidth] = useState(500)
const [dropdownWidth, setDropdownWidth] = useState(400)
useEffect(() => {
const measure = () => {
if (inputRef.current) {
setDropdownWidth(inputRef.current.parentElement?.offsetWidth || 500)
setDropdownWidth(inputRef.current.parentElement?.offsetWidth || 400)
}
}
measure()
@@ -181,15 +181,12 @@ export function AutocompleteSearch({
}}
>
<PopoverAnchor asChild>
<div className='relative flex h-9 w-[500px] items-center rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] transition-colors focus-within:border-[var(--surface-14)] focus-within:ring-1 focus-within:ring-ring hover:border-[var(--surface-14)] dark:bg-[var(--surface-9)] dark:hover:border-[var(--surface-13)]'>
<div className='relative flex h-[32px] w-[400px] items-center rounded-[8px] bg-[var(--surface-5)]'>
{/* Search Icon */}
<Search
className='ml-2.5 h-4 w-4 flex-shrink-0 text-muted-foreground'
strokeWidth={2}
/>
<Search className='mr-[6px] ml-[8px] h-[14px] w-[14px] flex-shrink-0 text-[var(--text-subtle)]' />
{/* Scrollable container for badges */}
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
<div className='flex flex-1 items-center gap-[6px] overflow-x-auto pr-[6px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{/* Applied Filter Badges */}
{appliedFilters.map((filter, index) => (
<Button
@@ -197,7 +194,7 @@ export function AutocompleteSearch({
variant='outline'
className={cn(
'h-6 flex-shrink-0 gap-1 rounded-[6px] px-2 text-[11px]',
highlightedBadgeIndex === index && 'border-white dark:border-white'
highlightedBadgeIndex === index && 'border'
)}
onClick={(e) => {
e.preventDefault()
@@ -238,7 +235,7 @@ export function AutocompleteSearch({
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
className='min-w-[100px] flex-1 border-0 bg-transparent font-sans text-foreground text-sm outline-none placeholder:text-[var(--text-muted)]'
className='min-w-[80px] flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none outline-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0 md:text-sm'
/>
</div>
@@ -246,10 +243,10 @@ export function AutocompleteSearch({
{(hasFilters || hasTextSearch) && (
<button
type='button'
className='mr-2.5 flex h-5 w-5 flex-shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground'
className='mr-[8px] ml-[6px] flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover:text-[var(--text-secondary)]'
onClick={clearAll}
>
<X className='h-4 w-4' />
<X className='h-[14px] w-[14px]' />
</button>
)}
</div>
@@ -290,7 +287,7 @@ export function AutocompleteSearch({
{sections.map((section) => (
<div key={section.title}>
<div className='border-border/50 border-t px-3 py-1.5 font-medium text-[11px] text-[var(--text-muted)] uppercase tracking-wide'>
<div className='border-[var(--divider)] border-t px-3 py-1.5 font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
{section.title}
</div>
{section.suggestions.map((suggestion) => {
@@ -345,7 +342,7 @@ export function AutocompleteSearch({
// Single section layout
<div className='py-1'>
{suggestionType === 'filters' && (
<div className='border-border/50 border-b px-3 py-1.5 font-medium text-[11px] text-[var(--text-muted)] uppercase tracking-wide'>
<div className='border-[var(--divider)] border-b px-3 py-1.5 font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
SUGGESTED FILTERS
</div>
)}

View File

@@ -0,0 +1,4 @@
export { Controls } from './components/controls'
export { NotificationSettings } from './components/notifications'
export { AutocompleteSearch } from './components/search'
export { LogsToolbar } from './logs-toolbar'

Some files were not shown because too many files have changed in this diff Show More