diff --git a/.claude/rules/constitution.md b/.claude/rules/constitution.md index 6881c060ee8..62dd7df3c56 100644 --- a/.claude/rules/constitution.md +++ b/.claude/rules/constitution.md @@ -23,7 +23,8 @@ Sim is the **AI workspace** where teams build and run AI agents. Not a workflow | The product | "AI workspace" | "workflow tool", "automation platform", "agent framework" | | Building | "build agents", "create agents" | "create workflows" (unless describing the workflow module specifically) | | Visual builder | "workflow builder" or "visual builder" | "canvas", "graph editor" | -| Mothership | "Mothership" (capitalized) | "chat", "AI assistant", "copilot" | +| The agent | "Sim" — you talk to Sim | "Mothership", "copilot", "AI assistant" | +| The chat surface | "Chat" (capitalized, the module) | "Mothership", "copilot" | | Deployment | "deploy", "ship" | "publish", "activate" | | Audience | "teams", "builders" | "users", "customers" (in marketing copy) | | What agents do | "automate real work" | "automate tasks", "automate workflows" | @@ -50,7 +51,7 @@ When describing Sim, always lead with the most differentiated claim: | Module | One-liner | |--------|-----------| -| **Mothership** | Your AI command center. Build and manage everything in natural language. | +| **Chat** | Your AI command center. Talk to Sim — build and manage everything in natural language. | | **Workflows** | The visual builder. Connect blocks, models, and integrations into agent logic. | | **Knowledge Base** | Your agents' memory. Upload docs, sync sources, build vector databases. | | **Tables** | A database, built in. Store, query, and wire structured data into agent runs. | @@ -65,7 +66,8 @@ When describing Sim, always lead with the most differentiated claim: - Never promise unshipped features - Never use jargon ("RAG", "vector database", "MCP") without plain-English explanation on public pages - Avoid "agentic workforce" as a primary term — use "AI agents" +- Never say "Mothership" or "copilot" — the agent is "Sim", the surface is "Chat" (in run logs the trigger reads "Sim agent") ## Vision -Sim becomes the default environment where teams build AI agents — not a tool you visit for one task, but a workspace you live in. Workflows are one module; Mothership is another. The workspace is the constant; the interface adapts. +Sim becomes the default environment where teams build AI agents — not a tool you visit for one task, but a workspace you live in. Workflows are one module; Chat is another. The workspace is the constant; the interface adapts. diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index 9adf86506be..9e096c2a9bd 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -22,13 +22,15 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items - **`Chip` / `ChipLink`** — the pill button (` - - {copied ? 'Copied!' : copyLabel} - - } - /> - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx index d0d65691293..e13c3ce7685 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx @@ -19,7 +19,7 @@ export function UnsavedChangesModal({ open, onOpenChange, onDiscard }: UnsavedCh onOpenChange={onOpenChange} srTitle='Unsaved Changes' title='Unsaved Changes' - description='You have unsaved changes. Are you sure you want to discard them?' + text='You have unsaved changes. Are you sure you want to discard them?' dismissLabel='Keep editing' confirm={{ label: 'Discard Changes', onClick: onDiscard }} /> diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts index 0c9b0befe52..3c076b766fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts @@ -1,6 +1,5 @@ export { AddPeopleModal } from './components/add-people-modal' export { CHIP_FIELD_INPUT, CHIP_FIELD_SHELL } from './components/chip-field' -export { CopyableValueField } from './components/copyable-value-field' export { CredentialDetailHeading } from './components/credential-detail-heading' export { CredentialDetailLayout } from './components/credential-detail-layout' export { CredentialMembersSection } from './components/credential-members-section' diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index dd30b61d6d1..4c9c20a7d2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -3,6 +3,7 @@ export type { ErrorBoundaryProps, ErrorStateProps } from './error' export { ErrorShell, ErrorState } from './error' export { InlineRenameInput } from './inline-rename-input' export { MessageActions } from './message-actions' +export { FloatingOverflowText } from './resource/components/floating-overflow-text' export { ownerCell } from './resource/components/owner-cell' export { type ChromeActionSpec, diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx index 5328e163c47..5dbb7656374 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx @@ -86,7 +86,7 @@ export function ResourceChromeFallback({ sort={hasSort ? { options: [], active: null, onSort: noop } : undefined} filter={hasFilter ? { content: null } : undefined} /> - {columns ? : null} + {columns ? : null} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 375e1c1dada..3007aa402da 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -13,6 +13,7 @@ import { createPortal } from 'react-dom' import { Chip, ChipChevronDown, + chipContentIconClass, chipGeometryClass, chipVariants, DropdownMenu, @@ -175,17 +176,21 @@ export const ResourceHeader = memo(function ResourceHeader({ ) }) ) : ( - - {TitleIcon && } + /** + * Root titles are short static labels ("Tables", "Files"), so the + * span is non-shrinkable and the label never truncates — matching + * the `shrink-0` guarantee the breadcrumb root crumb gets from + * {@link getBreadcrumbSegmentClassName}. Without this, the + * `flex-1` left column collapses during transient initial-load + * layout (the JS-driven `--sidebar-width` settling) and the title + * CSS-truncates to "T…" while the `shrink-0` actions hold width. + */ + + {TitleIcon && } {titleLabel && ( )} @@ -267,7 +272,7 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({ if (editing?.isEditing) { return ( - {Icon && } + {Icon && } - {Icon && } + {Icon && } ) @@ -421,11 +426,11 @@ function BreadcrumbLocationPopover({ className )} > - - + + {rootBreadcrumb?.label && ( @@ -485,6 +490,18 @@ function LocationFocusVeil({ boundaryRef: React.RefObject }) { const [bounds, setBounds] = useState({ top: 0, left: 0 }) + /** + * Portal-mount gate. The veil must render `null` on BOTH the server render + * and the first client (hydration) render — branching on + * `typeof document === 'undefined'` made the two renders diverge, which + * failed hydration and forced React to regenerate the whole page tree on + * the client (a visible header flash during load). + */ + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) useEffect(() => { if (!visible) return @@ -507,7 +524,7 @@ function LocationFocusVeil({ } }, [boundaryRef, visible]) - if (typeof document === 'undefined') return null + if (!mounted) return null return createPortal(
- sortValues?: Record } export interface SelectableConfig { @@ -108,12 +103,19 @@ interface ResourceProps { * - `Resource.Options` — required, the search/filter/sort toolbar * - `Resource.Table` — optional; swap for any custom body (dashboard, grid, …) * - * The shell owns the fixed column layout; the children own their own chrome. + * Invariant: the shell renders identically for every consumer. Consumers supply + * content (columns, rows, cells) and behavior (handlers, configs) only — no + * prop changes the shell's chrome, spacing, or structure. The only sanctioned + * variation is replacing `Resource.Table` with a custom body. + * + * The shell owns the fixed column layout and is the positioning context for + * absolutely-positioned overlays (action bars, slide-out sidebars); the + * children own their own chrome. */ function ResourceRoot({ children, onContextMenu }: ResourceProps) { return (
{children} @@ -124,52 +126,50 @@ function ResourceRoot({ children, onContextMenu }: ResourceProps) { interface ResourceTableProps { columns: ResourceColumn[] rows: ResourceRow[] - defaultSort?: string - sort?: SortConfig selectedRowId?: string | null selectable?: SelectableConfig rowDragDrop?: RowDragDropConfig onRowClick?: (rowId: string) => void onRowHover?: (rowId: string) => void onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void - isLoading?: boolean onLoadMore?: () => void hasMore?: boolean isLoadingMore?: boolean pagination?: PaginationConfig - emptyMessage?: string + /** + * Sanctioned overlay slot. Rendered absolutely against the table region + * (action bars, slide-out sidebars, drop targets). The overlay owns its own + * chrome and positioning; it never alters the table's rendering. + */ overlay?: ReactNode } /** * Data table body, module-private and exposed only as `Resource.Table` — the * compound member is the sole way consumers render it. + * + * Chrome guarantee: the ``, ``, and column headers render + * unconditionally — no prop or row state (empty, loading, error) ever drops + * them. Structural additions (checkbox column, load-more sentinel, pagination + * bar) are driven purely by which configs the consumer supplies and always + * render the canonical chrome. */ const ResourceTable = memo(function ResourceTable({ columns, rows, - defaultSort, - sort: externalSort, selectedRowId, selectable, rowDragDrop, onRowClick, onRowHover, onRowContextMenu, - isLoading, onLoadMore, hasMore, isLoadingMore, pagination, - emptyMessage, overlay, }: ResourceTableProps) { const loadMoreRef = useRef(null) - const sortEnabled = defaultSort != null - const [internalSort, setInternalSort] = useState<{ column: string; direction: 'asc' | 'desc' }>({ - column: defaultSort ?? '', - direction: 'desc', - }) const [contextMenuRowId, setContextMenuRowId] = useState(null) @@ -201,24 +201,6 @@ const ResourceTable = memo(function ResourceTable({ } }, [contextMenuRowId]) - const handleSort = useCallback((column: string, direction: 'asc' | 'desc') => { - setInternalSort({ column, direction }) - }, []) - - const displayRows = useMemo(() => { - if (!sortEnabled || externalSort) return rows - return [...rows].sort((a, b) => { - const col = internalSort.column - const aVal = a.sortValues?.[col] ?? a.cells[col]?.label ?? '' - const bVal = b.sortValues?.[col] ?? b.cells[col]?.label ?? '' - const cmp = - typeof aVal === 'number' && typeof bVal === 'number' - ? aVal - bVal - : String(aVal).localeCompare(String(bVal)) - return internalSort.direction === 'asc' ? -cmp : cmp - }) - }, [rows, internalSort, sortEnabled, externalSort]) - useEffect(() => { if (!onLoadMore || !hasMore) return const el = loadMoreRef.current @@ -242,22 +224,9 @@ const ResourceTable = memo(function ResourceTable({ [selectable] ) - /** - * While loading, the table chrome (column headers) renders with an empty body - * and the rows "just load in" — never a skeleton, and never a false - * empty-state (the empty message is gated on `!isLoading`). - */ - if (!isLoading && rows.length === 0 && emptyMessage) { - return ( -
- {emptyMessage} -
- ) - } - return (
-
+
@@ -273,41 +242,18 @@ const ResourceTable = memo(function ResourceTable({ /> )} - {columns.map((col) => { - if (!sortEnabled) { - return ( - - ) - } - const isActive = internalSort.column === col.id - const SortIcon = internalSort.direction === 'asc' ? ArrowUp : ArrowDown - return ( - - ) - })} + {columns.map((col) => ( + + ))} - {displayRows.map((row) => ( + {rows.map((row) => ( - Are you sure you want to delete{' '} - {fileName ? ( - {fileName} - ) : ( - `${totalCount} item${totalCount === 1 ? '' : 's'}` - )} - ? {consequence} - - } + text={[ + 'Are you sure you want to delete ', + fileName + ? { text: fileName, bold: true } + : `${totalCount} item${totalCount === 1 ? '' : 's'}`, + `? ${consequence}`, + ]} confirm={{ label: 'Delete', onClick: onDelete, diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index b96ef517aa5..4e60ec6b085 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -190,8 +190,7 @@ export function Files() { }, [permissionConfig.hideFilesTab, router, workspaceId]) const { data: files = EMPTY_WORKSPACE_FILES, isLoading, error } = useWorkspaceFiles(workspaceId) - const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS, isLoading: foldersLoading } = - useWorkspaceFileFolders(workspaceId) + const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useWorkspaceFileFolders(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) const uploadFile = useUploadWorkspaceFile() const deleteFile = useDeleteWorkspaceFile() @@ -437,14 +436,6 @@ export function Files() { owner: ownerCell(folder.userId, members), updated: timeCell(folder.updatedAt), }, - sortValues: { - name: folder.name, - size: folderSizeMap.get(folder.id) ?? -1, - type: 'Folder', - created: new Date(folder.createdAt).getTime(), - updated: new Date(folder.updatedAt).getTime(), - owner: members?.find((m) => m.userId === folder.userId)?.name ?? '', - }, })) const fileRows = filteredFiles.map((file) => { @@ -467,14 +458,6 @@ export function Files() { owner: ownerCell(file.uploadedBy, members), updated: timeCell(file.updatedAt), }, - sortValues: { - name: file.name, - size: file.size, - type: formatFileType(file.type, file.name), - created: new Date(file.uploadedAt).getTime(), - updated: new Date(file.updatedAt).getTime(), - owner: members?.find((m) => m.userId === file.uploadedBy)?.name ?? '', - }, } return row }) @@ -1565,7 +1548,7 @@ export function Files() { }, [router, workspaceId]) const loadingBreadcrumbs = useMemo( - () => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }], + () => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '…' }], [handleNavigateToFiles] ) @@ -1684,12 +1667,6 @@ export function Files() { const hasActiveFilters = typeFilter.length > 0 || sizeFilter.length > 0 || uploadedByFilter.length > 0 - const emptyMessage = debouncedSearchTerm - ? `No files match "${debouncedSearchTerm}"` - : hasActiveFilters - ? 'No files match the active filters' - : undefined - const filterContent = useMemo(() => { const typeDisplayLabel = typeFilter.length === 0 @@ -1841,19 +1818,19 @@ export function Files() { if (fileIdFromRoute && !selectedFile && isLoading) { return ( -
+
-
+ ) } if (selectedFile) { return ( <> -
+ -
+ {children} -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx index 0e5bcd3c98d..208a358b975 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx @@ -85,7 +85,7 @@ export function ThinkingBlock({
- Mothership + Sim void + /** Opens the menu anchored at a viewport position (caret or trigger rect). */ + open: (anchor: { left: number; top: number }, options?: { mention?: boolean }) => void close: () => void moveActive: (delta: number) => void selectActive: () => boolean @@ -46,23 +47,27 @@ export interface PlusMenuHandle { /** * Box and typography shared by the textarea and its mirror overlay — both must * produce identical line wrapping so the overlay text sits exactly over the - * (transparent) textarea text. + * (transparent) textarea text. The scale is the canonical chip text-field + * scale ({@link ChipTextarea}: `text-sm`, default tracking), so the editor + * reads identically in the chat input and inside chip modals — one size, + * everywhere. */ const FIELD_MIRROR_CLASSES = cn( - 'm-0 box-border min-h-[24px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent', - 'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]' + 'm-0 box-border min-h-[20px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent', + 'px-1 py-1 font-body text-sm leading-[20px]' ) /** * The textarea grows to its full content height (`h-auto`, no internal scroll); * the shared scroller clips and scrolls it. Its text is transparent so the - * mirror overlay shows through; only the caret paints. + * mirror overlay shows through; only the caret paints. The placeholder uses + * the canonical `--text-muted`, matching every other chip text field. */ export const TEXTAREA_BASE_CLASSES = cn( FIELD_MIRROR_CLASSES, 'block h-auto resize-none overflow-hidden', 'text-transparent caret-[var(--text-primary)] outline-none', - 'placeholder:font-[380] placeholder:text-[var(--text-subtle)]', + 'placeholder:text-[var(--text-muted)]', 'focus-visible:ring-0 focus-visible:ring-offset-0' ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts index 9ea5a44d428..bf00d079201 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts @@ -25,6 +25,13 @@ export { DropOverlay } from './drop-overlay' export { MicButton } from './mic-button' export type { AvailableResourceGroup } from './plus-menu-dropdown' export { PlusMenuDropdown } from './plus-menu-dropdown' +export type { + PromptEditorInstance, + PromptEditorKeyPolicy, + PromptEditorProps, + UsePromptEditorProps, +} from './prompt-editor' +export { PromptEditor, usePromptEditor } from './prompt-editor' export { SendButton } from './send-button' export type { SkillsMenuHandle } from './skills-menu-dropdown/skills-menu-dropdown' export { SkillsMenuDropdown } from './skills-menu-dropdown/skills-menu-dropdown' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index 6ca744fceae..530379f1dbb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -10,9 +10,8 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, - Tooltip, } from '@/components/emcn' -import { Plus, Workflow } from '@/components/emcn/icons' +import { Workflow } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { buildFileFolderTree, @@ -57,19 +56,12 @@ export const PlusMenuDropdown = React.memo( const [search, setSearch] = useState('') const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null) const [activeIndex, setActiveIndex] = useState(0) - const buttonRef = useRef(null) const searchRef = useRef(null) const contentRef = useRef(null) const doOpen = useCallback( - (anchor?: { left: number; top: number }, options?: { mention?: boolean }) => { - if (anchor) { - setAnchorPos(anchor) - } else { - const rect = buttonRef.current?.getBoundingClientRect() - if (!rect) return - setAnchorPos({ left: rect.left, top: rect.top }) - } + (anchor: { left: number; top: number }, options?: { mention?: boolean }) => { + setAnchorPos(anchor) setIsMention(!!options?.mention) setOpen(true) setSearch('') @@ -252,162 +244,146 @@ export const PlusMenuDropdown = React.memo( } return ( - <> - - -
+ +
+ + + {!isMention && ( + { + setSearch(e.target.value) + setActiveIndex(0) }} + onKeyDown={handleSearchKeyDown} /> - - - {!isMention && ( - { - setSearch(e.target.value) - setActiveIndex(0) - }} - onKeyDown={handleSearchKeyDown} - /> - )} -
- {/* Always-mounted; swapping this subtree with filtered results makes Radix's + )} +
+ {/* Always-mounted; swapping this subtree with filtered results makes Radix's menu FocusScope steal focus from the search input back to the content root. */} - - - - - - - - Add resources - - + {/* Plain buttons, not DropdownMenuItem: mount/unmount must not mutate Radix's + menu Collection, or FocusScope restores focus to the content root. */} + {filteredItems !== null && + (filteredItems.length > 0 ? ( + filteredItems.map(({ type, item }, index) => { + const config = getResourceConfig(type) + const isActive = index === activeIndex + return ( + + ) + }) + ) : ( +
+ No results +
+ ))} +
+ + ) }) ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/index.ts new file mode 100644 index 00000000000..6b3e30415db --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/index.ts @@ -0,0 +1,7 @@ +export { PromptEditor, type PromptEditorProps } from './prompt-editor' +export { + type PromptEditorInstance, + type PromptEditorKeyPolicy, + type UsePromptEditorProps, + usePromptEditor, +} from './use-prompt-editor' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx new file mode 100644 index 00000000000..b2633d28841 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -0,0 +1,204 @@ +'use client' + +import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react' +import { cn } from '@/lib/core/utils/cn' +import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' +import { + OVERLAY_CLASSES, + SCROLLER_CLASSES, + TEXTAREA_BASE_CLASSES, +} from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' +import { PlusMenuDropdown } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown' +import type { + PromptEditorInstance, + PromptEditorKeyPolicy, +} from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor' +import { SkillsMenuDropdown } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown' +import { + computeMentionHighlightRanges, + extractContextTokens, + stripMentionTrigger, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' + +export interface PromptEditorProps extends PromptEditorKeyPolicy { + /** Editor instance from {@link usePromptEditor}. */ + editor: PromptEditorInstance + /** Placeholder shown while the editor is empty. */ + placeholder?: string + /** Focuses the editor (caret at end) on mount. */ + autoFocus?: boolean + /** + * Layout/sizing only — a height cap (`max-h-[200px]`) or fill (`flex-1`) + * for the scroll container. The text chrome is owned by the editor. + */ + className?: string + /** Accessible label for the textarea. */ + 'aria-label'?: string +} + +/** + * The rendered face of {@link usePromptEditor}: a transparent-text textarea + * under a mirror overlay that paints mention chips (icon + label) in place of + * their tokens, plus the caret-anchored `@`-resource and `/`-skill menus. The + * textarea grows to its content height inside a single scroller, so overlay + * and caret co-scroll natively and never drift. + * + * Everything intrinsic to editing lives here; host-specific keys (Enter + * submit, ArrowUp history) are threaded in via {@link PromptEditorKeyPolicy}. + * + * @example + * ```tsx + * const editor = usePromptEditor({ workspaceId }) + * + * ``` + */ +export function PromptEditor({ + editor, + placeholder, + autoFocus = false, + className, + 'aria-label': ariaLabel, + onSubmit, + onArrowUpOnEmpty, +}: PromptEditorProps) { + const { textareaRef, value } = editor + + useLayoutEffect(() => { + const textarea = textareaRef.current + if (!textarea) return + // Grow the textarea to its full content height; the scroller caps the + // visible height and scrolls textarea + overlay together natively. + textarea.style.height = 'auto' + textarea.style.height = `${textarea.scrollHeight}px` + }, [value, textareaRef]) + + useEffect(() => { + if (autoFocus) editor.focusAtEnd() + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only focus + }, []) + + /** + * Clicking the editor's empty regions (padding, space below the last line) + * focuses the textarea; clicks on the textarea itself keep native caret + * placement. + */ + const handleSurfaceClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === textareaRef.current) return + if ((e.target as HTMLElement).closest('button')) return + textareaRef.current?.focus() + }, + [textareaRef] + ) + + const overlayContent = useMemo(() => { + const contexts = editor.contexts + + if (!value) { + return {'\u00A0'} + } + + if (contexts.length === 0) { + const displayText = value.endsWith('\n') ? `${value}\u200B` : value + return {displayText} + } + + const tokens = extractContextTokens(contexts) + const ranges = computeMentionHighlightRanges(value, tokens) + + if (ranges.length === 0) { + const displayText = value.endsWith('\n') ? `${value}\u200B` : value + return {displayText} + } + + const elements: React.ReactNode[] = [] + let lastIndex = 0 + for (let i = 0; i < ranges.length; i++) { + const range = ranges[i] + + if (range.start > lastIndex) { + const before = value.slice(lastIndex, range.start) + elements.push({before}) + } + + const mentionLabel = stripMentionTrigger(range.token) + const matchingCtx = contexts.find((c) => c.label === mentionLabel) + + const mentionIconNode = matchingCtx ? ( + + ) : null + + elements.push( + + + {/* Invisible trigger glyph keeps the overlay's advance identical to + the transparent textarea; the icon centers over its slot. */} + {range.token.charAt(0)} + {mentionIconNode} + + {mentionLabel} + + ) + lastIndex = range.end + } + + const tail = value.slice(lastIndex) + if (tail) { + const displayTail = tail.endsWith('\n') ? `${tail}\u200B` : tail + elements.push({displayTail}) + } + + return elements.length > 0 ? elements : {'\u00A0'} + }, [value, editor.contexts]) + + return ( +
+ {/* Sizer for textarea + overlay: the textarea grows to full content + height and the overlay fills it via `inset-0`, so both are flow + children of the same scroller and co-scroll natively. */} +
+ + +
- {col.header} - - - + {col.header} +