Skip to content
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { handleKeyboardActivation, Label, Switch } from '@sim/emcn'
import { handleKeyboardActivation, Label, Switch, toast } from '@sim/emcn'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { getKnowledgeChunkContract } from '@/lib/api/contracts/knowledge'
Expand Down Expand Up @@ -59,6 +59,7 @@ export function ChunkEditor({

const [editedContent, setEditedContent] = useState(isCreateMode ? '' : chunkContent)
const [savedContent, setSavedContent] = useState(chunkContent)
const validationToastIdRef = useRef<string | null>(null)
const [tokenizerOn, setTokenizerOn] = useState(false)
const [hoveredTokenIndex, setHoveredTokenIndex] = useState<number | null>(null)
const savedContentRef = useRef(chunkContent)
Expand Down Expand Up @@ -108,8 +109,18 @@ export function ChunkEditor({
const handleSave = useCallback(async () => {
const content = editedContentRef.current
const trimmed = content.trim()
if (trimmed.length === 0) throw new Error('Content cannot be empty')
if (trimmed.length > 10000) throw new Error('Content exceeds maximum length')
/** Toast every failed attempt, replacing the previous validation toast so retries refresh instead of stack. */
const failValidation = (message: string): never => {
if (validationToastIdRef.current) toast.dismiss(validationToastIdRef.current)
validationToastIdRef.current = toast.error(message)
throw new Error(message)
}
if (trimmed.length === 0) failValidation('Content cannot be empty')
if (trimmed.length > 10000) failValidation('Content exceeds maximum length (10,000 characters)')
Comment thread
cursor[bot] marked this conversation as resolved.
if (validationToastIdRef.current) {
toast.dismiss(validationToastIdRef.current)
validationToastIdRef.current = null
}

if (isCreateMode) {
const created = await createChunk({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,12 +197,28 @@ function ExpandedCellEditor({
textareaRef,
}: ExpandedCellEditorProps) {
const [draftValue, setDraftValue] = useState(initialValue)
const [parseError, setParseError] = useState<string | null>(null)

const handleSave = () => {
// `displayToStorage` only normalizes dates — it returns null for anything else.
// Fall back to the raw draft for non-date columns, matching the inline editor.
const raw = displayToStorage(draftValue) ?? draftValue
const cleaned = cleanCellValue(raw, column)
let cleaned: unknown
try {
cleaned = cleanCellValue(raw, column)
} catch {
setParseError('Invalid JSON')
return
}
Comment thread
cursor[bot] marked this conversation as resolved.
/** `cleanCellValue` nulls unparseable dates/numbers instead of throwing — reject rather than silently clear. */
if (
cleaned === null &&
draftValue.trim() !== '' &&
(column.type === 'date' || column.type === 'number')
) {
setParseError(column.type === 'date' ? 'Invalid date' : 'Invalid number')
return
}
onSave(rowId, column.key, cleaned, 'blur')
Comment thread
greptile-apps[bot] marked this conversation as resolved.
onClose()
}
Expand All @@ -219,16 +235,23 @@ function ExpandedCellEditor({
<textarea
ref={textareaRef}
value={draftValue}
onChange={(e) => setDraftValue(e.target.value)}
onChange={(e) => {
setDraftValue(e.target.value)
setParseError(null)
}}
onKeyDown={handleTextareaKeyDown}
className='min-h-0 flex-1 resize-none bg-transparent px-2.5 py-2 font-sans text-[var(--text-primary)] text-small outline-none placeholder:text-[var(--text-muted)]'
spellCheck={false}
autoCorrect='off'
/>
<div className='flex items-center justify-between border-[var(--border)] border-t bg-[var(--surface-2)] px-2 py-1.5'>
<span className='text-[var(--text-tertiary)] text-caption'>
<kbd className='font-mono'>↵</kbd> save · <kbd className='font-mono'>esc</kbd> cancel
</span>
{parseError ? (
<span className='text-[var(--text-error)] text-caption'>{parseError}</span>
) : (
<span className='text-[var(--text-tertiary)] text-caption'>
<kbd className='font-mono'>↵</kbd> save · <kbd className='font-mono'>esc</kbd> cancel
</span>
)}
<div className='flex items-center gap-1.5'>
<Button variant='ghost' size='sm' onClick={onClose}>
Cancel
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import { useCallback, useEffect, useRef, useState } from 'react'
import { Calendar, cn, Popover, PopoverAnchor, PopoverContent } from '@sim/emcn'
import { Calendar, cn, Popover, PopoverAnchor, PopoverContent, toast } from '@sim/emcn'
import type { ColumnDefinition } from '@/lib/table'
import type { SaveReason } from '../../../types'
import {
Expand Down Expand Up @@ -44,6 +44,7 @@ function InlineDateEditor({
const [draft, setDraft] = useState(() =>
initialCharacter !== undefined ? initialCharacter : storageToDisplay(storedValue)
)
const [invalid, setInvalid] = useState(false)

const pickerValue = displayToStorage(draft) || storedValue || undefined

Expand All @@ -64,13 +65,24 @@ function InlineDateEditor({
const doSave = useCallback(
(reason: SaveReason, storageVal?: string) => {
if (doneRef.current) return
doneRef.current = true
clearTimeout(blurTimeoutRef.current)
const raw = storageVal ?? displayToStorage(draft) ?? draft
const val = raw && !Number.isNaN(Date.parse(raw)) ? raw : null
onSave(val, reason)
if (raw && Number.isNaN(Date.parse(raw))) {
if (reason === 'blur') {
if (!invalid) toast.error('Invalid date')
doneRef.current = true
onCancel()
} else {
toast.error('Invalid date')
setInvalid(true)
inputRef.current?.focus()
}
return
Comment thread
cursor[bot] marked this conversation as resolved.
}
doneRef.current = true
onSave(raw || null, reason)
},
[draft, onSave]
[draft, invalid, onSave, onCancel]
)

const handleKeyDown = useCallback(
Expand Down Expand Up @@ -116,12 +128,16 @@ function InlineDateEditor({
ref={inputRef}
type='text'
value={draft}
onChange={(e) => setDraft(e.target.value)}
onChange={(e) => {
setDraft(e.target.value)
setInvalid(false)
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
placeholder='mm/dd/yyyy'
className={cn(
'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none'
'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none',
invalid && 'text-[var(--text-error)]'
)}
/>
<Popover open onOpenChange={handlePickerOpenChange}>
Expand All @@ -146,6 +162,7 @@ function InlineTextEditor({
const [draft, setDraft] = useState(() =>
initialCharacter !== undefined ? initialCharacter : formatValueForInput(value, column.type)
)
const [invalid, setInvalid] = useState(false)
const doneRef = useRef(false)

useEffect(() => {
Expand All @@ -161,14 +178,33 @@ function InlineTextEditor({
}
}, [])

const rejectDraft = (message: string, reason: SaveReason) => {
if (reason === 'blur') {
if (!invalid) toast.error(message)
doneRef.current = true
onCancel()
} else {
toast.error(message)
setInvalid(true)
inputRef.current?.focus()
}
}

const doSave = (reason: SaveReason) => {
if (doneRef.current) return
doneRef.current = true
let cleaned: unknown
try {
onSave(cleanCellValue(draft, column), reason)
cleaned = cleanCellValue(draft, column)
} catch {
onCancel()
rejectDraft('Invalid JSON', reason)
return
}
if (column.type === 'number' && cleaned === null && draft.trim() !== '') {
rejectDraft('Invalid number', reason)
return
}
doneRef.current = true
onSave(cleaned, reason)
}

const handleKeyDown = (e: React.KeyboardEvent) => {
Expand All @@ -193,11 +229,17 @@ function InlineTextEditor({
type='text'
inputMode={isNumber ? 'decimal' : undefined}
value={draft ?? ''}
onChange={(e) => setDraft(e.target.value)}
onChange={(e) => {
setDraft(e.target.value)
setInvalid(false)
}}
onKeyDown={handleKeyDown}
onWheel={handleEditorWheel}
onBlur={() => doSave('blur')}
className='w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none'
className={cn(
'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none',
invalid && 'text-[var(--text-error)]'
)}
/>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const RichMarkdownField = dynamic(
}
)

/** A high cap that only guards against abuse — no visible counter; normal descriptions never reach it. */
/** A high cap that only guards against abuse — a counter message appears only when exceeded. */
const MAX_DESCRIPTION_LENGTH = 50_000

interface VersionDescriptionModalProps {
Expand Down Expand Up @@ -136,6 +136,11 @@ export function VersionDescriptionModal({
<span className='font-medium text-[var(--text-primary)]'>{versionName}</span>
</span>
}
error={
isTooLong
? `Description exceeds the ${MAX_DESCRIPTION_LENGTH.toLocaleString()}-character limit (currently ${description.length.toLocaleString()})`
: undefined
}
>
<RichMarkdownField
value={description}
Expand All @@ -145,7 +150,7 @@ export function VersionDescriptionModal({
maxHeight={420}
disabled={isGenerating}
isStreaming={isGenerating}
error={description.length > MAX_DESCRIPTION_LENGTH}
error={isTooLong}
workspaceId={workspaceId}
disableTagging
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useEffect, useState } from 'react'
import { useState } from 'react'
import {
ChipModal,
ChipModalBody,
Expand All @@ -9,6 +9,7 @@ import {
ChipModalFooter,
ChipModalHeader,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'

interface CreateWorkspaceModalProps {
open: boolean
Expand All @@ -27,17 +28,25 @@ export function CreateWorkspaceModal({
isCreating,
}: CreateWorkspaceModalProps) {
const [name, setName] = useState('')
const [error, setError] = useState<string | null>(null)

useEffect(() => {
const [prevOpen, setPrevOpen] = useState(open)
if (prevOpen !== open) {
setPrevOpen(open)
if (open) {
setName('')
setError(null)
}
}, [open])
}

const handleSubmit = async () => {
const trimmed = name.trim()
if (!trimmed || isCreating) return
await onConfirm(trimmed)
try {
await onConfirm(trimmed)
} catch (err) {
setError(getErrorMessage(err, 'Failed to create workspace'))
}
}

const handleKeyDown = (e: React.KeyboardEvent) => {
Expand All @@ -47,6 +56,11 @@ export function CreateWorkspaceModal({
}
}

const handleNameChange = (value: string) => {
setName(value)
setError(null)
}

return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Create workspace'>
<ChipModalHeader onClose={() => onOpenChange(false)}>Create workspace</ChipModalHeader>
Expand All @@ -55,14 +69,14 @@ export function CreateWorkspaceModal({
type='input'
title='Name'
value={name}
onChange={setName}
onChange={handleNameChange}
placeholder='Workspace name'
maxLength={100}
autoComplete='off'
disabled={isCreating}
required
/>
<ChipModalError>{undefined}</ChipModalError>
<ChipModalError>{error ?? undefined}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export function useWorkspaceManagement({
await switchWorkspace(newWorkspace)
} catch (error) {
logger.error('Error creating workspace:', error)
throw error
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
Loading