fix: copilot, improvement: tables, mothership

This commit is contained in:
Emir Karabeg
2026-03-09 21:52:47 -07:00
parent 5c6797a0bd
commit 9b10e4464e
20 changed files with 404 additions and 564 deletions

View File

@@ -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<string, unknown> = {
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,

View File

@@ -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<string, unknown> = {
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 })
}
}

View File

@@ -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 (
<div className='flex h-full flex-col overflow-hidden'>
<FileViewer key={file.id} file={file} workspaceId={workspaceId} canEdit={true} />
<FileViewer
key={file.id}
file={file}
workspaceId={workspaceId}
canEdit={true}
showPreview={isPreviewable(file)}
/>
</div>
)
}

View File

@@ -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<Exclude<MothershipResourceType, 'file'>, 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 (
<div className='flex shrink-0 gap-[2px] overflow-x-auto border-[var(--border)] border-b px-[12px]'>
{resources.map((resource) => (
<button
key={resource.id}
type='button'
onClick={() => onSelect(resource.id)}
className={cn(
'shrink-0 cursor-pointer border-b-[2px] px-[12px] py-[10px] text-[13px] transition-colors',
activeId === resource.id
? 'border-[var(--text-primary)] font-medium text-[var(--text-primary)]'
: 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
)}
>
{resource.title}
</button>
))}
<div className='flex shrink-0 items-center gap-[6px] overflow-x-auto border-[var(--border)] border-b px-[16px] py-[8.5px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{resources.map((resource) => {
const Icon = getResourceIcon(resource)
const isActive = activeId === resource.id
return (
<Button
key={resource.id}
variant='subtle'
onClick={() => onSelect(resource.id)}
className={cn(
'shrink-0 border border-transparent bg-transparent px-[8px] py-[4px] text-[12px]',
isActive && 'border-[var(--border)] bg-[var(--surface-4)]'
)}
>
<Icon className={cn('mr-[6px] h-[14px] w-[14px] text-[var(--text-icon)]')} />
{resource.title}
</Button>
)
})}
</div>
)
}

View File

@@ -46,7 +46,7 @@ export interface UseChatReturn {
size: number
}>
) => Promise<void>
stopGeneration: () => void
stopGeneration: () => Promise<void>
chatBottomRef: React.RefObject<HTMLDivElement | null>
resources: MothershipResource[]
activeResourceId: string | null
@@ -140,6 +140,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
const sendingRef = useRef(false)
const toolArgsMapRef = useRef<Map<string, Record<string, unknown>>>(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 () => {

View File

@@ -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
```

View File

@@ -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 (
<Popover open={contextMenu.isOpen} onOpenChange={(open) => !open && onClose()}>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenu.position.x}px`,
top: `${contextMenu.position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent
align='start'
side='bottom'
sideOffset={4}
border
className='!min-w-[160px] !rounded-[8px] !bg-[var(--bg)] !p-[6px] shadow-sm'
>
{contextMenu.columnName && (
<div className={MENU_ITEM} onClick={onEditCell} role='menuitem'>
<Pencil />
Edit cell
</div>
)}
<div className={MENU_ITEM} onClick={onInsertAbove} role='menuitem'>
<ArrowUp />
Insert row above
</div>
<div className={MENU_ITEM} onClick={onInsertBelow} role='menuitem'>
<ArrowDown />
Insert row below
</div>
<div className={MENU_SEPARATOR} role='separator' />
<DropdownMenu open={contextMenu.isOpen} onOpenChange={(open) => !open && onClose()}>
<DropdownMenuTrigger asChild>
<div
className={cn(MENU_ITEM, 'text-[var(--text-error)] hover:text-[var(--text-error)]')}
onClick={onDelete}
role='menuitem'
>
<Trash />
{deleteLabel}
</div>
</PopoverContent>
</Popover>
style={{
position: 'fixed',
left: `${contextMenu.position.x}px`,
top: `${contextMenu.position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' side='bottom' sideOffset={4} className='min-w-[160px]'>
{isEmptyCell ? (
<DropdownMenuItem onSelect={onAddData}>
<Plus />
Add data
</DropdownMenuItem>
) : (
<>
{contextMenu.columnName && (
<DropdownMenuItem onSelect={onEditCell}>
<Pencil />
Edit cell
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={onInsertAbove}>
<ArrowUp />
Insert row above
</DropdownMenuItem>
<DropdownMenuItem onSelect={onInsertBelow}>
<ArrowDown />
Insert row below
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className='text-[var(--text-error)] focus:text-[var(--text-error)]'
onSelect={onDelete}
>
<Trash />
{deleteLabel}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,5 +1,4 @@
export * from './context-menu'
export * from './row-modal'
export * from './schema-modal'
export * from './table'
export * from './table-filter'

View File

@@ -1 +0,0 @@
export { SchemaModal } from './schema-modal'

View File

@@ -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 (
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent size='md'>
<ModalHeader>Table Schema</ModalHeader>
<ModalBody className='max-h-[60vh] overflow-y-auto'>
<div className='mb-[10px] flex items-center justify-between gap-[8px]'>
{tableName ? (
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
{tableName}
</span>
) : (
<div />
)}
<Badge variant='gray' size='sm'>
{columnCount} {columnCount === 1 ? 'column' : 'columns'}
</Badge>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Column</TableHead>
<TableHead>Type</TableHead>
<TableHead>Constraints</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{columns.map((column) => (
<TableRow key={column.name}>
<TableCell className='font-mono'>{column.name}</TableCell>
<TableCell>
<Badge variant={getTypeBadgeVariant(column.type)} size='sm'>
{column.type}
</Badge>
</TableCell>
<TableCell>
<div className='flex items-center gap-[6px]'>
{column.required && (
<Badge variant='red' size='sm'>
required
</Badge>
)}
{column.unique && (
<Badge variant='purple' size='sm'>
unique
</Badge>
)}
{!column.required && !column.unique && (
<span className='text-[var(--text-muted)]'>None</span>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={onClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -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<string, unknown>) => {
const data = response?.data as Record<string, unknown> | undefined
const row = data?.row as Record<string, unknown> | 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 (
<tr key={`gap-${position}`}>
<tr key={`gap-${position}`} onContextMenu={(e) => onContextMenu(e, position)}>
<td
className={GAP_CHECKBOX_CLASS}
onMouseDown={(e) => {
@@ -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 (
<tr key={`placeholder-${i}`}>
<tr key={`placeholder-${i}`} onContextMenu={(e) => onContextMenu(e, globalRowIndex)}>
<td
className={cn(CELL_CHECKBOX, 'group/checkbox cursor-pointer text-center')}
onMouseDown={(e) => {

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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<HTMLDivElement | null>
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 (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{onViewSchema && (
<PopoverItem
onClick={() => {
onViewSchema()
onClose()
}}
>
View Schema
</PopoverItem>
)}
{onRename && (
<PopoverItem
onClick={() => {
onRename()
onClose()
}}
>
Rename
</PopoverItem>
)}
{(onViewSchema || onRename) && (onCopyId || onDelete) && <PopoverDivider />}
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' side='bottom' sideOffset={4}>
{onCopyId && (
<PopoverItem
onClick={() => {
onCopyId()
onClose()
}}
>
<DropdownMenuItem onSelect={onCopyId}>
<Copy />
Copy ID
</PopoverItem>
</DropdownMenuItem>
)}
{onCopyId && onDelete && <PopoverDivider />}
{onCopyId && onDelete && <DropdownMenuSeparator />}
{onDelete && (
<PopoverItem
disabled={disableDelete}
onClick={() => {
onDelete()
onClose()
}}
>
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
<Trash />
Delete
</PopoverItem>
</DropdownMenuItem>
)}
</PopoverContent>
</Popover>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -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<HTMLDivElement | null>
onClose: () => void
onCreateTable?: () => void
disableCreate?: boolean
@@ -14,40 +19,34 @@ interface TablesListContextMenuProps {
export function TablesListContextMenu({
isOpen,
position,
menuRef,
onClose,
onCreateTable,
disableCreate = false,
}: TablesListContextMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' side='bottom' sideOffset={4}>
{onCreateTable && (
<PopoverItem
disabled={disableCreate}
onClick={() => {
onCreateTable()
onClose()
}}
>
<DropdownMenuItem disabled={disableCreate} onSelect={onCreateTable}>
<Plus />
Create table
</PopoverItem>
</DropdownMenuItem>
)}
</PopoverContent>
</Popover>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -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<TableDefinition | null>(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: <TableIcon className='h-[14px] w-[14px]' />,
label: table.name,
content:
tableRename.editingId === table.id ? (
<span className='flex min-w-0 items-center gap-[12px] font-medium text-[14px] text-[var(--text-body)]'>
<span className='flex-shrink-0 text-[var(--text-icon)]'>
<TableIcon className='h-[14px] w-[14px]' />
</span>
<InlineRenameInput
value={tableRename.editValue}
onChange={tableRename.setEditValue}
onSubmit={tableRename.submitRename}
onCancel={tableRename.cancelRename}
/>
</span>
) : undefined,
},
columns: {
icon: <Columns3 className='h-[14px] w-[14px]' />,
@@ -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() {
<TablesListContextMenu
isOpen={isListContextMenuOpen}
position={listContextMenuPosition}
menuRef={listMenuRef}
onClose={closeListContextMenu}
onCreateTable={handleCreateTable}
disableCreate={userPermissions.canEdit !== true || createTable.isPending}
@@ -227,12 +192,7 @@ export function Tables() {
<TableContextMenu
isOpen={isRowContextMenuOpen}
position={rowContextMenuPosition}
menuRef={rowMenuRef}
onClose={closeRowContextMenu}
onRename={() => {
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
</Button>
<Button variant='default' onClick={handleDelete} disabled={deleteTable.isPending}>
<Button variant='destructive' onClick={handleDelete} disabled={deleteTable.isPending}>
{deleteTable.isPending ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{activeTable && (
<SchemaModal
isOpen={isSchemaModalOpen}
onClose={() => {
setIsSchemaModalOpen(false)
setActiveTable(null)
}}
columns={activeTable.schema.columns}
tableName={activeTable.name}
/>
)}
</>
)
}

View File

@@ -394,7 +394,7 @@ export const Panel = memo(function Panel() {
<>
<aside
ref={panelRef}
className='panel-container relative shrink-0 overflow-hidden bg-[var(--surface-1)]'
className='panel-container relative shrink-0 overflow-hidden bg-[var(--bg)]'
aria-label='Workflow panel'
>
<div className='flex h-full flex-col border-[var(--border)] border-l pt-[14px]'>

View File

@@ -64,7 +64,7 @@ const OutputCodeContent = React.memo(function OutputCodeContent({
code={code}
showGutter
language={language}
className='m-0 min-h-full rounded-none border-0 bg-[var(--surface-1)] dark:bg-[var(--surface-1)]'
className='m-0 min-h-full rounded-none border-0 bg-[var(--bg)] dark:bg-[var(--bg)]'
paddingLeft={8}
gutterStyle={{ backgroundColor: 'transparent' }}
wrapText={wrapText}
@@ -295,7 +295,7 @@ export const OutputPanel = React.memo(function OutputPanel({
return (
<>
<div
className='absolute top-0 right-0 bottom-0 flex flex-col border-[var(--border)] border-l bg-[var(--surface-1)]'
className='absolute top-0 right-0 bottom-0 flex flex-col border-[var(--border)] border-l bg-[var(--bg)]'
style={{ width: `${outputPanelWidth}px` }}
>
{/* Horizontal Resize Handle */}
@@ -309,7 +309,7 @@ export const OutputPanel = React.memo(function OutputPanel({
{/* Header */}
<div
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center justify-between bg-[var(--surface-1)] pr-[16px] pl-[10px]'
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center justify-between bg-[var(--bg)] pr-[16px] pl-[10px]'
onClick={handleHeaderClick}
>
<div className='flex items-center'>
@@ -529,7 +529,7 @@ export const OutputPanel = React.memo(function OutputPanel({
{/* Search Overlay */}
{isOutputSearchActive && (
<div
className='absolute top-[30px] right-[8px] z-30 flex h-[34px] items-center gap-[6px] rounded-b-[4px] border border-[var(--border)] border-t-0 bg-[var(--surface-1)] px-[6px] shadow-sm'
className='absolute top-[30px] right-[8px] z-30 flex h-[34px] items-center gap-[6px] rounded-b-[4px] border border-[var(--border)] border-t-0 bg-[var(--bg)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
data-toolbar-root
data-search-active='true'

View File

@@ -1279,7 +1279,7 @@ export const Terminal = memo(function Terminal() {
<aside
ref={terminalRef}
className={clsx(
'terminal-container relative shrink-0 overflow-hidden border-[var(--border)] border-t bg-[var(--surface-1)]',
'terminal-container relative shrink-0 overflow-hidden border-[var(--border)] border-t bg-[var(--bg)]',
isToggling && 'transition-[height] duration-100 ease-out'
)}
onTransitionEnd={handleTransitionEnd}
@@ -1305,7 +1305,7 @@ export const Terminal = memo(function Terminal() {
>
{/* Header */}
<div
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center justify-between bg-[var(--surface-1)] pr-[16px] pl-[16px]'
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center justify-between bg-[var(--bg)] pr-[16px] pl-[16px]'
onClick={handleHeaderClick}
>
{/* Left side - Logs label */}

View File

@@ -334,6 +334,8 @@ export function useDeleteTable(workspaceId: string) {
/**
* Create a row in a table.
* Populates the cache on success so the new row is immediately available
* without waiting for the background refetch triggered by invalidation.
*/
export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) {
const queryClient = useQueryClient()
@@ -353,6 +355,20 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext)
return res.json()
},
onSuccess: (response) => {
const row = (response as { data?: { row?: TableRow } })?.data?.row as TableRow | undefined
if (!row) return
queryClient.setQueriesData<TableRowsResponse>(
{ queryKey: tableKeys.rowsRoot(tableId) },
(old) => {
if (!old) return old
if (old.rows.some((r) => r.id === row.id)) return old
const rows: TableRow[] = [...old.rows, row].sort((a, b) => a.position - b.position)
return { ...old, rows, totalCount: old.totalCount + 1 }
}
)
},
onSettled: () => {
invalidateRowCount(queryClient, workspaceId, tableId)
},