diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 543a49e9c2..603cadaf5e 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' @@ -123,14 +123,20 @@ export async function POST(req: NextRequest) { timestamp: new Date().toISOString(), } - await db + const [updated] = await db .update(copilotChats) .set({ - messages: [...conversationHistory, userMsg], + messages: sql`${copilotChats.messages} || ${JSON.stringify([userMsg])}::jsonb`, conversationId: userMessageId, updatedAt: new Date(), }) .where(eq(copilotChats.id, actualChatId)) + .returning({ messages: copilotChats.messages }) + + if (updated) { + const freshMessages: any[] = Array.isArray(updated.messages) ? updated.messages : [] + conversationHistory = freshMessages.filter((m: any) => m.id !== userMessageId) + } } const [workspaceContext, userPermission] = await Promise.all([ @@ -177,13 +183,6 @@ export async function POST(req: NextRequest) { onComplete: async (result: OrchestratorResult) => { if (!actualChatId) return - const userMessage = { - id: userMessageId, - role: 'user' as const, - content: message, - timestamp: new Date().toISOString(), - } - const assistantMessage: Record = { id: crypto.randomUUID(), role: 'assistant' as const, @@ -194,16 +193,29 @@ export async function POST(req: NextRequest) { assistantMessage.toolCalls = result.toolCalls } - const updatedMessages = [...conversationHistory, userMessage, assistantMessage] - try { - await db - .update(copilotChats) - .set({ - messages: updatedMessages, - conversationId: null, - }) + const [row] = await db + .select({ messages: copilotChats.messages }) + .from(copilotChats) .where(eq(copilotChats.id, actualChatId)) + .limit(1) + + const msgs: any[] = Array.isArray(row?.messages) ? row.messages : [] + const userIdx = msgs.findIndex((m: any) => m.id === userMessageId) + const alreadyHasResponse = + userIdx >= 0 && + userIdx + 1 < msgs.length && + (msgs[userIdx + 1] as any)?.role === 'assistant' + + if (!alreadyHasResponse) { + await db + .update(copilotChats) + .set({ + messages: sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`, + conversationId: sql`CASE WHEN ${copilotChats.conversationId} = ${userMessageId} THEN NULL ELSE ${copilotChats.conversationId} END`, + }) + .where(eq(copilotChats.id, actualChatId)) + } } catch (error) { logger.error(`[${tracker.requestId}] Failed to persist chat messages`, { chatId: actualChatId, diff --git a/apps/sim/app/api/mothership/chat/stop/route.ts b/apps/sim/app/api/mothership/chat/stop/route.ts new file mode 100644 index 0000000000..0ff092b11e --- /dev/null +++ b/apps/sim/app/api/mothership/chat/stop/route.ts @@ -0,0 +1,65 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' + +const logger = createLogger('MothershipChatStopAPI') + +const StopSchema = z.object({ + chatId: z.string(), + streamId: z.string(), + content: z.string(), +}) + +/** + * POST /api/mothership/chat/stop + * Persists partial assistant content when the user stops a stream mid-response. + * Clears conversationId so the server-side onComplete won't duplicate the message. + */ +export async function POST(req: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { chatId, streamId, content } = StopSchema.parse(await req.json()) + + const setClause: Record = { + conversationId: null, + updatedAt: new Date(), + } + + if (content.trim()) { + const assistantMessage = { + id: crypto.randomUUID(), + role: 'assistant' as const, + content, + timestamp: new Date().toISOString(), + } + setClause.messages = sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb` + } + + await db + .update(copilotChats) + .set(setClause) + .where( + and( + eq(copilotChats.id, chatId), + eq(copilotChats.userId, session.user.id), + eq(copilotChats.conversationId, streamId) + ) + ) + + return NextResponse.json({ success: true }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }) + } + logger.error('Error stopping chat stream:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index c0b9ddf38b..8fdbb72ed9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -2,7 +2,10 @@ import { lazy, Suspense, useMemo } from 'react' import { Skeleton } from '@/components/emcn' -import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer' +import { + FileViewer, + isPreviewable, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer' import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' @@ -68,7 +71,13 @@ function EmbeddedFile({ workspaceId, fileId }: EmbeddedFileProps) { return (
- +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index 3214eda0c4..a1ccaab5e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -1,7 +1,15 @@ 'use client' +import type { ElementType } from 'react' +import { Button } from '@/components/emcn' +import { Table as TableIcon } from '@/components/emcn/icons' +import { WorkflowIcon } from '@/components/icons' +import { getDocumentIcon } from '@/components/icons/document-icons' import { cn } from '@/lib/core/utils/cn' -import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' +import type { + MothershipResource, + MothershipResourceType, +} from '@/app/workspace/[workspaceId]/home/types' interface ResourceTabsProps { resources: MothershipResource[] @@ -9,28 +17,44 @@ interface ResourceTabsProps { onSelect: (id: string) => void } +const RESOURCE_ICONS: Record, ElementType> = { + table: TableIcon, + workflow: WorkflowIcon, +} + +function getResourceIcon(resource: MothershipResource): ElementType { + if (resource.type === 'file') { + return getDocumentIcon('', resource.title) + } + return RESOURCE_ICONS[resource.type] +} + /** * Horizontal tab bar for switching between mothership resources. - * Mirrors the role of ResourceHeader in the Resource abstraction. + * Renders each resource as a subtle Button matching ResourceHeader actions. */ export function ResourceTabs({ resources, activeId, onSelect }: ResourceTabsProps) { return ( -
- {resources.map((resource) => ( - - ))} +
+ {resources.map((resource) => { + const Icon = getResourceIcon(resource) + const isActive = activeId === resource.id + + return ( + + ) + })}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 428e2fd166..9a6ca2f53f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -46,7 +46,7 @@ export interface UseChatReturn { size: number }> ) => Promise - stopGeneration: () => void + stopGeneration: () => Promise chatBottomRef: React.RefObject resources: MothershipResource[] activeResourceId: string | null @@ -140,6 +140,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet const sendingRef = useRef(false) const toolArgsMapRef = useRef>>(new Map()) const streamGenRef = useRef(0) + const streamingContentRef = useRef('') const isHomePage = pathname.endsWith('/home') @@ -212,6 +213,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet let lastWorkflowId: string | null = null let runningText = '' + streamingContentRef.current = '' toolArgsMapRef.current.clear() const ensureTextBlock = (): ContentBlock => { @@ -283,6 +285,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet const tb = ensureTextBlock() tb.content = (tb.content ?? '') + chunk runningText += chunk + streamingContentRef.current = runningText flush() } break @@ -427,6 +430,24 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet [workspaceId, queryClient, addResource] ) + const persistPartialResponse = useCallback(async () => { + const chatId = chatIdRef.current + const streamId = streamIdRef.current + if (!chatId || !streamId) return + + const content = streamingContentRef.current + try { + const res = await fetch('/api/mothership/chat/stop', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chatId, streamId, content }), + }) + if (res.ok) streamingContentRef.current = '' + } catch (err) { + logger.warn('Failed to persist partial response', err) + } + }, []) + const invalidateChatQueries = useCallback(() => { const activeChatId = chatIdRef.current if (activeChatId) { @@ -495,6 +516,9 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet ) => { if (!message.trim() || !workspaceId) return + if (sendingRef.current) { + await persistPartialResponse() + } abortControllerRef.current?.abort() const gen = ++streamGenRef.current @@ -566,17 +590,20 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet } } }, - [workspaceId, queryClient, processSSEStream, finalize] + [workspaceId, queryClient, processSSEStream, finalize, persistPartialResponse] ) - const stopGeneration = useCallback(() => { + const stopGeneration = useCallback(async () => { + if (sendingRef.current) { + await persistPartialResponse() + } streamGenRef.current++ abortControllerRef.current?.abort() abortControllerRef.current = null sendingRef.current = false setIsSending(false) invalidateChatQueries() - }, [invalidateChatQueries]) + }, [invalidateChatQueries, persistPartialResponse]) useEffect(() => { return () => { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/TABLES_CAPABILITIES.md b/apps/sim/app/workspace/[workspaceId]/tables/TABLES_CAPABILITIES.md deleted file mode 100644 index fa61817ba8..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/TABLES_CAPABILITIES.md +++ /dev/null @@ -1,194 +0,0 @@ -# Tables UI Capabilities - -## Main Page (List View) - -### Header -- Icon (blue table icon in rounded badge) + title "Tables" -- Description subtitle: "Create and manage data tables for your workflows." - -### Actions Bar -- **Search**: Debounced (300ms) text input filtering by table name and description -- **Create Table**: Button with permission gate (`canEdit`), tooltip when disabled - -### Content Grid -- Responsive card grid: 1 col (mobile) → 2 → 3 → 4 (xl) -- States: loading (8 skeleton cards), error (message), empty (search-aware message) - -### Context Menu (Background) -- Right-click on empty area → "Create table" (permission-gated) - ---- - -## Table Card - -### Display -- **Name**: Truncated, bold -- **Short ID**: Badge showing `tb-{first 8 chars}` -- **Column count**: Icon + count -- **Row count**: Icon + count -- **Last updated**: Relative time, absolute date on tooltip hover -- **Description**: 2-line clamp or "No description" - -### Interactions -- Click → navigate to table detail (`/workspace/{id}/tables/{tableId}`) -- Right-click → context menu - -### Context Menu (Card) -- View Schema → opens schema modal -- Copy ID → clipboard -- Delete → confirmation modal (permission-gated) - -### Modals -- **Delete Confirmation**: Shows table name + row count warning, "cannot be undone" -- **Schema Viewer**: Read-only table of columns with name, type badge, constraints (required/unique) - ---- - -## Create Table Modal - -### Form Fields -- **Name**: Required, enforces lowercase + underscores pattern -- **Description**: Optional textarea -- **Columns**: Dynamic list (minimum 1) - -### Column Editor -- Column name (text input) -- Type selector: `string` | `number` | `boolean` | `date` | `json` -- Required toggle (checkbox) -- Unique toggle (checkbox) -- Add/remove columns (minimum 1 enforced) - -### Validation -- Name required -- At least one column -- No duplicate column names - ---- - -## Table Detail Page (Inner View) - -### Header Bar -- Breadcrumb: "Tables" (back link) → table name -- Row count badge -- View Schema button (Info icon) -- Refresh button (RefreshCw icon) - -### Query Builder -- **Add Filter**: Column selector → operator → value, logical AND/OR chaining -- **Add Sort**: Column selector → direction (asc/desc) -- **Apply**: Execute query -- **Clear All**: Reset filters/sorts -- **Add Row**: Opens add row modal - -### Data Grid -- Sticky header row with column names, type badges, required markers -- Row selection via checkboxes -- Select-all checkbox in header -- Cell rendering by type: - - **String**: Truncated at 50 chars, click to view full, double-click to inline edit - - **Number**: Double-click to inline edit - - **Date**: Double-click to inline edit - - **Boolean**: Single-click to toggle - - **JSON**: Click to view formatted, double-click to edit in modal - - **Null**: Shows "—", double-click to edit - -### Inline Cell Editor -- Input field for string/number/date -- Enter to save, Escape to cancel, blur to save - -### Action Bar (Selection) -- Shows selected count -- Clear selection button -- Delete selected button - -### Row Context Menu -- Edit row -- Delete row - -### Pagination -- Previous/Next buttons -- "Page X of Y (N rows)" label -- Hidden when single page -- 100 rows per page - -### Modals (Detail) -- **Add Row**: Form with field per column, typed inputs (text/textarea/checkbox) -- **Edit Row**: Pre-filled form, update button -- **Delete Row(s)**: Confirmation dialog -- **Cell Viewer**: Full content display with copy button (JSON pretty-printed, date formatted) - ---- - -## Underlying Considerations - -### Data Layer -- React Query for all data fetching (no Zustand store) -- Hooks: `useTablesList`, `useTable`, `useTableRows`, `useCreateTable`, `useDeleteTable`, `useCreateTableRow`, `useUpdateTableRow`, `useDeleteTableRow`, `useDeleteTableRows` -- Optimistic updates on row mutations -- Query key factory pattern (`tableKeys`) - -### API Surface -| Endpoint | Methods | Purpose | -|----------|---------|---------| -| `/api/table` | GET, POST | List tables, create table | -| `/api/table/[tableId]` | GET, DELETE | Get table, delete table | -| `/api/table/[tableId]/rows` | GET, POST, PUT, DELETE | Query/insert/update/delete rows | -| `/api/table/[tableId]/rows/[rowId]` | GET, PATCH, DELETE | Single row CRUD | -| `/api/table/[tableId]/rows/upsert` | POST | Upsert by unique column | - -### Table Service (`lib/table/service.ts`) -- Used by executor, background jobs, and tests -- Full CRUD: `getTableById`, `listTables`, `countTables`, `createTable`, `deleteTable` -- Row operations: `insertRow`, `batchInsertRows`, `upsertRow`, `queryRows`, `getRowById`, `updateRow`, `deleteRow` -- Bulk operations: `updateRowsByFilter`, `deleteRowsByFilter`, `deleteRowsByIds` - -### Permissions -- Create/delete gated by `canEdit` from `useUserPermissionsContext()` -- API routes enforce workspace access via `checkAccess()` -- Page-level: `hideTablesTab` permission config - -### File Structure -``` -tables/ -├── page.tsx # Server component (auth, permissions) -├── tables.tsx # Client list view -├── layout.tsx # Flex layout wrapper -├── lib/ -│ └── utils.ts # formatRelativeTime, formatAbsoluteDate -├── components/ -│ ├── index.ts # Barrel exports -│ ├── table-card.tsx # Card component -│ ├── table-context-menu.tsx # Card context menu -│ ├── tables-list-context-menu.tsx # Background context menu -│ ├── create-modal.tsx # Create table form -│ ├── empty-state.tsx # Empty state display -│ ├── error-state.tsx # Error state display -│ └── loading-state.tsx # Skeleton loading -└── [tableId]/ - ├── page.tsx # Detail server component - ├── lib/ - │ ├── constants.ts # ROWS_PER_PAGE, STRING_TRUNCATE_LENGTH - │ └── utils.ts # getTypeBadgeVariant, cleanCellValue, formatValueForInput - ├── hooks/ - │ ├── use-table-data.ts # Combined table + rows hook - │ ├── use-row-selection.ts # Selection state - │ └── use-context-menu.ts # Context menu state - └── components/ - ├── index.ts - ├── table-viewer.tsx # Main detail view - ├── header-bar.tsx - ├── action-bar.tsx - ├── query-builder/ - │ ├── index.tsx - │ ├── filter-row.tsx - │ └── sort-row.tsx - ├── cell-renderer.tsx - ├── inline-cell-editor.tsx - ├── cell-viewer-modal.tsx - ├── row-modal.tsx - ├── schema-modal.tsx - ├── context-menu.tsx - ├── table-row-cells.tsx - ├── body-states.tsx - └── pagination.tsx -``` diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index 92e0c57299..cd371a6e51 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -1,17 +1,18 @@ -import { Popover, PopoverAnchor, PopoverContent } from '@/components/emcn' -import { ArrowDown, ArrowUp, Pencil, Trash } from '@/components/emcn/icons' -import { cn } from '@/lib/core/utils/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/emcn' +import { ArrowDown, ArrowUp, Pencil, Plus, Trash } from '@/components/emcn/icons' import type { ContextMenuState } from '../../types' -const MENU_ITEM = - 'relative flex cursor-default select-none items-center gap-[8px] rounded-[5px] px-[8px] py-[5px] font-medium text-[12px] text-[var(--text-secondary)] outline-none transition-colors hover:bg-[var(--surface-4)] hover:text-[var(--text-primary)] [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0' - -const MENU_SEPARATOR = '-mx-[6px] my-[6px] h-px bg-[var(--border-1)]' - interface ContextMenuProps { contextMenu: ContextMenuState onClose: () => void onEditCell: () => void + onAddData: () => void onDelete: () => void onInsertAbove: () => void onInsertBelow: () => void @@ -22,55 +23,64 @@ export function ContextMenu({ contextMenu, onClose, onEditCell, + onAddData, onDelete, onInsertAbove, onInsertBelow, selectedRowCount = 1, }: ContextMenuProps) { + const isEmptyCell = !contextMenu.row const deleteLabel = selectedRowCount > 1 ? `Delete ${selectedRowCount} rows` : 'Delete row' return ( - !open && onClose()}> - - - {contextMenu.columnName && ( -
- - Edit cell -
- )} -
- - Insert row above -
-
- - Insert row below -
-
+ !open && onClose()}> +
- - {deleteLabel} -
- - + style={{ + position: 'fixed', + left: `${contextMenu.position.x}px`, + top: `${contextMenu.position.y}px`, + width: '1px', + height: '1px', + pointerEvents: 'none', + }} + tabIndex={-1} + aria-hidden + /> +
+ + {isEmptyCell ? ( + + + Add data + + ) : ( + <> + {contextMenu.columnName && ( + + + Edit cell + + )} + + + Insert row above + + + + Insert row below + + + + + {deleteLabel} + + + )} + +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts index c11fc9fd85..bc0da8a071 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts @@ -1,5 +1,4 @@ export * from './context-menu' export * from './row-modal' -export * from './schema-modal' export * from './table' export * from './table-filter' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/schema-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/schema-modal/index.ts deleted file mode 100644 index ba968ecead..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/schema-modal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SchemaModal } from './schema-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/schema-modal/schema-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/schema-modal/schema-modal.tsx deleted file mode 100644 index 735e6f0282..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/schema-modal/schema-modal.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { - Badge, - Button, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/emcn' -import type { ColumnDefinition } from '@/lib/table' -import { getTypeBadgeVariant } from '../../utils' - -interface SchemaModalProps { - isOpen: boolean - onClose: () => void - columns: ColumnDefinition[] - tableName?: string -} - -export function SchemaModal({ isOpen, onClose, columns, tableName }: SchemaModalProps) { - const columnCount = columns.length - - return ( - - - Table Schema - -
- {tableName ? ( - - {tableName} - - ) : ( -
- )} - - {columnCount} {columnCount === 1 ? 'column' : 'columns'} - -
- - - - - Column - Type - Constraints - - - - {columns.map((column) => ( - - {column.name} - - - {column.type} - - - -
- {column.required && ( - - required - - )} - {column.unique && ( - - unique - - )} - {!column.required && !column.unique && ( - None - )} -
-
-
- ))} -
-
- - - - - - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 18df87b165..c622db64ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -199,6 +199,7 @@ export function Table({ const { contextMenu, handleRowContextMenu: baseHandleRowContextMenu, + handleEmptyCellContextMenu: baseHandleEmptyCellContextMenu, closeContextMenu, } = useContextMenu() @@ -371,18 +372,49 @@ export function Table({ closeContextMenu() }, [contextMenu.row, closeContextMenu]) + const handleAddData = useCallback(() => { + if (contextMenu.rowIndex === null || !contextMenu.columnName) { + closeContextMenu() + return + } + const column = columnsRef.current.find((c) => c.name === contextMenu.columnName) + if (!column || column.type === 'boolean') { + closeContextMenu() + return + } + setSelectionAnchor({ + rowIndex: contextMenu.rowIndex, + colIndex: columnsRef.current.findIndex((c) => c.name === contextMenu.columnName), + }) + setSelectionFocus(null) + setEditingEmptyCell({ rowIndex: contextMenu.rowIndex, columnName: contextMenu.columnName }) + setInitialCharacter(null) + closeContextMenu() + }, [contextMenu.rowIndex, contextMenu.columnName, closeContextMenu]) + + const resolveColumnFromEvent = useCallback((e: React.MouseEvent) => { + const td = (e.target as HTMLElement).closest('td[data-col]') as HTMLElement | null + const colIndex = td ? Number.parseInt(td.getAttribute('data-col') || '-1', 10) : -1 + return colIndex >= 0 && colIndex < columnsRef.current.length + ? columnsRef.current[colIndex].name + : null + }, []) + const handleRowContextMenu = useCallback( (e: React.MouseEvent, row: TableRowType) => { setEditingCell(null) - const td = (e.target as HTMLElement).closest('td[data-col]') as HTMLElement | null - const colIndex = td ? Number.parseInt(td.getAttribute('data-col') || '-1', 10) : -1 - const columnName = - colIndex >= 0 && colIndex < columnsRef.current.length - ? columnsRef.current[colIndex].name - : null - baseHandleRowContextMenu(e, row, columnName) + baseHandleRowContextMenu(e, row, resolveColumnFromEvent(e)) }, - [baseHandleRowContextMenu] + [baseHandleRowContextMenu, resolveColumnFromEvent] + ) + + const handleEmptyCellRightClick = useCallback( + (e: React.MouseEvent, rowIndex: number) => { + setEditingCell(null) + setEditingEmptyCell(null) + baseHandleEmptyCellContextMenu(e, rowIndex, resolveColumnFromEvent(e)) + }, + [baseHandleEmptyCellContextMenu, resolveColumnFromEvent] ) const handleCellMouseDown = useCallback( @@ -811,19 +843,9 @@ export function Table({ createRef.current( { data: mergedData, position: rowIndex }, { - onSuccess: (response: Record) => { - const data = response?.data as Record | undefined - const row = data?.row as Record | undefined - const newRowId = row?.id as string | undefined - if (newRowId) { - setPendingPlaceholders((prev) => { - if (!prev[rowIndex]) return prev - return { ...prev, [rowIndex]: { ...prev[rowIndex], rowId: newRowId } } - }) - } - }, - onError: () => { + onSettled: () => { setPendingPlaceholders((prev) => { + if (!prev[rowIndex]) return prev const next = { ...prev } delete next[rowIndex] return next @@ -1178,6 +1200,7 @@ export function Table({ onDoubleClick={handleEmptyRowDoubleClick} onSave={handleEmptyRowSave} onCancel={handleEmptyRowCancel} + onContextMenu={handleEmptyCellRightClick} onCellMouseDown={handleCellMouseDown} onCellMouseEnter={handleCellMouseEnter} onRowMouseDown={handleRowMouseDown} @@ -1222,6 +1245,7 @@ export function Table({ onDoubleClick={handleEmptyRowDoubleClick} onSave={handleEmptyRowSave} onCancel={handleEmptyRowCancel} + onContextMenu={handleEmptyCellRightClick} onCellMouseDown={handleCellMouseDown} onCellMouseEnter={handleCellMouseEnter} onRowMouseDown={handleRowMouseDown} @@ -1269,6 +1293,7 @@ export function Table({ contextMenu={contextMenu} onClose={closeContextMenu} onEditCell={handleContextMenuEditCell} + onAddData={handleAddData} onDelete={handleContextMenuDelete} onInsertAbove={handleInsertRowAbove} onInsertBelow={handleInsertRowBelow} @@ -1352,6 +1377,7 @@ interface PositionGapRowsProps { onDoubleClick: (rowIndex: number, columnName: string) => void onSave: (rowIndex: number, columnName: string, value: unknown, reason: SaveReason) => void onCancel: () => void + onContextMenu: (e: React.MouseEvent, rowIndex: number) => void onCellMouseDown: (rowIndex: number, colIndex: number, shiftKey: boolean) => void onCellMouseEnter: (rowIndex: number, colIndex: number) => void onRowMouseDown: (rowIndex: number, shiftKey: boolean) => void @@ -1371,6 +1397,7 @@ const PositionGapRows = React.memo(function PositionGapRows({ onDoubleClick, onSave, onCancel, + onContextMenu, onCellMouseDown, onCellMouseEnter, onRowMouseDown, @@ -1385,7 +1412,7 @@ const PositionGapRows = React.memo(function PositionGapRows({ {Array.from({ length: capped }).map((_, i) => { const position = startPosition + i return ( - + onContextMenu(e, position)}> { @@ -1920,6 +1947,7 @@ interface PlaceholderRowsProps { onDoubleClick: (rowIndex: number, columnName: string) => void onSave: (rowIndex: number, columnName: string, value: unknown, reason: SaveReason) => void onCancel: () => void + onContextMenu: (e: React.MouseEvent, rowIndex: number) => void onCellMouseDown: (rowIndex: number, colIndex: number, shiftKey: boolean) => void onCellMouseEnter: (rowIndex: number, colIndex: number) => void onRowMouseDown: (rowIndex: number, shiftKey: boolean) => void @@ -1938,6 +1966,7 @@ function placeholderPropsAreEqual(prev: PlaceholderRowsProps, next: PlaceholderR prev.onDoubleClick !== next.onDoubleClick || prev.onSave !== next.onSave || prev.onCancel !== next.onCancel || + prev.onContextMenu !== next.onContextMenu || prev.onCellMouseDown !== next.onCellMouseDown || prev.onCellMouseEnter !== next.onCellMouseEnter || prev.onRowMouseDown !== next.onRowMouseDown || @@ -1985,6 +2014,7 @@ const PlaceholderRows = React.memo(function PlaceholderRows({ onDoubleClick, onSave, onCancel, + onContextMenu, onCellMouseDown, onCellMouseEnter, onRowMouseDown, @@ -2083,7 +2113,7 @@ const PlaceholderRows = React.memo(function PlaceholderRows({ const globalRowIndex = dataRowCount + i const pending = pendingPlaceholders[globalRowIndex] return ( - + onContextMenu(e, globalRowIndex)}> { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-context-menu.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-context-menu.ts index 193d78cf42..868f36f87a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-context-menu.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-context-menu.ts @@ -5,6 +5,11 @@ import type { ContextMenuState } from '../types' interface UseContextMenuReturn { contextMenu: ContextMenuState handleRowContextMenu: (e: React.MouseEvent, row: TableRow, columnName?: string | null) => void + handleEmptyCellContextMenu: ( + e: React.MouseEvent, + rowIndex: number, + columnName: string | null + ) => void closeContextMenu: () => void } @@ -13,6 +18,7 @@ export function useContextMenu(): UseContextMenuReturn { isOpen: false, position: { x: 0, y: 0 }, row: null, + rowIndex: null, columnName: null, }) @@ -24,12 +30,28 @@ export function useContextMenu(): UseContextMenuReturn { isOpen: true, position: { x: e.clientX, y: e.clientY }, row, + rowIndex: row.position, columnName: columnName ?? null, }) }, [] ) + const handleEmptyCellContextMenu = useCallback( + (e: React.MouseEvent, rowIndex: number, columnName: string | null) => { + e.preventDefault() + e.stopPropagation() + setContextMenu({ + isOpen: true, + position: { x: e.clientX, y: e.clientY }, + row: null, + rowIndex, + columnName, + }) + }, + [] + ) + const closeContextMenu = useCallback(() => { setContextMenu((prev) => ({ ...prev, isOpen: false })) }, []) @@ -37,6 +59,7 @@ export function useContextMenu(): UseContextMenuReturn { return { contextMenu, handleRowContextMenu, + handleEmptyCellContextMenu, closeContextMenu, } } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/types.ts index f18ad5b553..e3e4417d2c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/types.ts @@ -14,12 +14,14 @@ export interface QueryOptions { } /** - * State for the row context menu (right-click) + * State for the row context menu (right-click). + * When `row` is null and `rowIndex` is set, the menu targets an empty cell. */ export interface ContextMenuState { isOpen: boolean position: { x: number; y: number } row: TableRow | null + rowIndex: number | null columnName: string | null } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx index 1bc2bfb67f..ed199fdc72 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx @@ -1,20 +1,18 @@ 'use client' import { - Popover, - PopoverAnchor, - PopoverContent, - PopoverDivider, - PopoverItem, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, } from '@/components/emcn' +import { Copy, Trash } from '@/components/emcn/icons' interface TableContextMenuProps { isOpen: boolean position: { x: number; y: number } - menuRef: React.RefObject onClose: () => void - onRename?: () => void - onViewSchema?: () => void onCopyId?: () => void onDelete?: () => void disableDelete?: boolean @@ -23,75 +21,42 @@ interface TableContextMenuProps { export function TableContextMenu({ isOpen, position, - menuRef, onClose, - onRename, - onViewSchema, onCopyId, onDelete, disableDelete = false, }: TableContextMenuProps) { return ( - !open && onClose()} - variant='secondary' - size='sm' - > - - - {onViewSchema && ( - { - onViewSchema() - onClose() - }} - > - View Schema - - )} - {onRename && ( - { - onRename() - onClose() - }} - > - Rename - - )} - {(onViewSchema || onRename) && (onCopyId || onDelete) && } + !open && onClose()}> + +
+ + {onCopyId && ( - { - onCopyId() - onClose() - }} - > + + Copy ID - + )} - {onCopyId && onDelete && } + {onCopyId && onDelete && } {onDelete && ( - { - onDelete() - onClose() - }} - > + + Delete - + )} - - + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx index dad8fd0e83..8201acfc6f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/tables-list-context-menu/tables-list-context-menu.tsx @@ -1,11 +1,16 @@ 'use client' -import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/emcn' +import { Plus } from '@/components/emcn/icons' interface TablesListContextMenuProps { isOpen: boolean position: { x: number; y: number } - menuRef: React.RefObject onClose: () => void onCreateTable?: () => void disableCreate?: boolean @@ -14,40 +19,34 @@ interface TablesListContextMenuProps { export function TablesListContextMenu({ isOpen, position, - menuRef, onClose, onCreateTable, disableCreate = false, }: TablesListContextMenuProps) { return ( - !open && onClose()} - variant='secondary' - size='sm' - > - - + !open && onClose()}> + +
+ + {onCreateTable && ( - { - onCreateTable() - onClose() - }} - > + + Create table - + )} - - + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 9e9d3ff9c8..0a4c9038af 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -8,25 +8,13 @@ import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons' import type { TableDefinition } from '@/lib/table' import { generateUniqueTableName } from '@/lib/table/constants' import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components' -import { - InlineRenameInput, - ownerCell, - Resource, - timeCell, -} from '@/app/workspace/[workspaceId]/components' +import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { SchemaModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components' import { TablesListContextMenu } from '@/app/workspace/[workspaceId]/tables/components' import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import { - useCreateTable, - useDeleteTable, - useRenameTable, - useTablesList, -} from '@/hooks/queries/tables' +import { useCreateTable, useDeleteTable, useTablesList } from '@/hooks/queries/tables' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' -import { useInlineRename } from '@/hooks/use-inline-rename' const logger = createLogger('Tables') @@ -53,21 +41,14 @@ export function Tables() { } const deleteTable = useDeleteTable(workspaceId) const createTable = useCreateTable(workspaceId) - const renameTable = useRenameTable(workspaceId) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) - const [isSchemaModalOpen, setIsSchemaModalOpen] = useState(false) const [activeTable, setActiveTable] = useState(null) const [searchTerm, setSearchTerm] = useState('') - const tableRename = useInlineRename({ - onSave: (tableId, name) => renameTable.mutate({ tableId, name }), - }) - const { isOpen: isListContextMenuOpen, position: listContextMenuPosition, - menuRef: listMenuRef, handleContextMenu: handleListContextMenu, closeMenu: closeListContextMenu, } = useContextMenu() @@ -75,7 +56,6 @@ export function Tables() { const { isOpen: isRowContextMenuOpen, position: rowContextMenuPosition, - menuRef: rowMenuRef, handleContextMenu: handleRowCtxMenu, closeMenu: closeRowContextMenu, } = useContextMenu() @@ -94,20 +74,6 @@ export function Tables() { name: { icon: , label: table.name, - content: - tableRename.editingId === table.id ? ( - - - - - - - ) : undefined, }, columns: { icon: , @@ -128,7 +94,7 @@ export function Tables() { updated: -new Date(table.updatedAt).getTime(), }, })), - [filteredTables, members, tableRename.editingId, tableRename.editValue] + [filteredTables, members] ) const handleContentContextMenu = useCallback( @@ -147,11 +113,11 @@ export function Tables() { const handleRowClick = useCallback( (rowId: string) => { - if (!isRowContextMenuOpen && tableRename.editingId !== rowId) { + if (!isRowContextMenuOpen) { router.push(`/workspace/${workspaceId}/tables/${rowId}`) } }, - [isRowContextMenuOpen, tableRename.editingId, router, workspaceId] + [isRowContextMenuOpen, router, workspaceId] ) const handleRowContextMenu = useCallback( @@ -218,7 +184,6 @@ export function Tables() { { - if (activeTable) tableRename.startRename(activeTable.id, activeTable.name) - }} - onViewSchema={() => setIsSchemaModalOpen(true)} onCopyId={() => { if (activeTable) navigator.clipboard.writeText(activeTable.id) }} @@ -262,24 +222,12 @@ export function Tables() { > Cancel - - - {activeTable && ( - { - setIsSchemaModalOpen(false) - setActiveTable(null) - }} - columns={activeTable.schema.columns} - tableName={activeTable.name} - /> - )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index ace03b409d..7ba9b3a2bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -394,7 +394,7 @@ export const Panel = memo(function Panel() { <>