Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
dd984b4
feat(tables): saved views with filter, sort, and column presets
TheodoreSpeaks Jul 25, 2026
308a448
fix(tables): views own column layout, preserve deep-linked sort, seed…
TheodoreSpeaks Jul 25, 2026
f4e3b07
fix(tables): merge view layout writes server-side, keep layout on sav…
TheodoreSpeaks Jul 25, 2026
9fb054c
fix(tables): send view saves as a merge patch so concurrent writes ca…
TheodoreSpeaks Jul 25, 2026
8ab2d52
fix(tables): persist explicit All selection, prune view state for del…
TheodoreSpeaks Jul 25, 2026
20150f7
fix(tables): key-order-stable dirty check, reset layout when switchin…
TheodoreSpeaks Jul 25, 2026
37f7db0
fix(tables): return 404 when patching a missing view
TheodoreSpeaks Jul 25, 2026
d04c96e
improvement(tables): order the bar filter, sort, columns and drop the…
TheodoreSpeaks Jul 25, 2026
f085726
fix(tables): record newly created columns in the active view's order
TheodoreSpeaks Jul 25, 2026
f139b61
Merge remote-tracking branch 'origin/staging' into feat/table-views
TheodoreSpeaks Jul 28, 2026
1ee10eb
fix(tables): don't write layout as a reader, clear dead sort, reset d…
TheodoreSpeaks Jul 28, 2026
37627a1
feat(tables): add New view to the views menu, starting from All
TheodoreSpeaks Jul 28, 2026
7614635
Merge remote-tracking branch 'origin/staging' into feat/table-views
TheodoreSpeaks Jul 28, 2026
86c7d26
chore(api): bump route-count baseline to 981 after staging merge
TheodoreSpeaks Jul 28, 2026
da8a17a
fix(tables): guard default demotion, use the applied filter for dirty…
TheodoreSpeaks Jul 28, 2026
6c7d144
fix(tables): clear state when the active view is deleted externally
TheodoreSpeaks Jul 28, 2026
caa192c
Merge remote-tracking branch 'origin/staging' into feat/table-views
TheodoreSpeaks Jul 28, 2026
78e840b
Merge remote-tracking branch 'origin/staging' into feat/table-views
TheodoreSpeaks Jul 28, 2026
1aae25b
revert(tables): drop the ineffective rows-query gate for view resolution
TheodoreSpeaks Jul 28, 2026
e1a294a
Merge remote-tracking branch 'origin/staging' into feat/table-views
TheodoreSpeaks Jul 28, 2026
acec909
feat(tables): enable saved views in the embedded mothership table
TheodoreSpeaks Jul 28, 2026
5496dee
fix(tables): resolve the views flag on the chat route too
TheodoreSpeaks Jul 28, 2026
20f7df6
improvement(tables): right-align the embedded run/stop control
TheodoreSpeaks Jul 28, 2026
8a52f80
fix(tables): live layout on new views, prune stored config, order cac…
TheodoreSpeaks Jul 28, 2026
987bbc2
fix(tables): live layout on save-as-view, reset it per view, ignore i…
TheodoreSpeaks Jul 28, 2026
4f51aad
fix(tables): route undo/redo column-layout writes to the active view
TheodoreSpeaks Jul 28, 2026
a75b290
merge: staging into feat/table-views
TheodoreSpeaks Jul 28, 2026
c584cde
fix(tables): scope layout undo to the view that recorded it
TheodoreSpeaks Jul 28, 2026
d8c279b
fix(tables): send only the layout keys an action changes
TheodoreSpeaks Jul 28, 2026
429d0e2
fix(tables): don't strand layout writes while views load, keep schema…
TheodoreSpeaks Jul 28, 2026
d5a12f1
fix(tables): flush layout buffered during load when the owner settles…
TheodoreSpeaks Jul 28, 2026
7d86bd3
merge: staging into feat/table-views
TheodoreSpeaks Jul 28, 2026
b614085
fix(tables): resolve undo layout ownership at write time, not dispatc…
TheodoreSpeaks Jul 28, 2026
e2acf9e
fix(tables): read layout from the grid instead of mirroring it
TheodoreSpeaks Jul 29, 2026
d4a1bc2
merge: staging into feat/table-views
TheodoreSpeaks Jul 29, 2026
be51268
fix(tables): flush unowned layout when the views fetch fails
TheodoreSpeaks Jul 29, 2026
91cb203
merge: staging into feat/table-views
TheodoreSpeaks Jul 29, 2026
b622f78
fix(tables): gate the on-screen layout restore by ownership, not just…
TheodoreSpeaks Jul 29, 2026
23dc262
fix(tables): route all layout writes through one owner-aware sink, re…
TheodoreSpeaks Jul 29, 2026
02717f7
merge: staging into feat/table-views
TheodoreSpeaks Jul 29, 2026
1576c41
fix(tables): capture the layout owner when a schema action is dispatched
TheodoreSpeaks Jul 29, 2026
b49a061
fix(tables): resolve views on list availability, not query success
TheodoreSpeaks Jul 29, 2026
ed59ca8
merge: staging into feat/table-views
TheodoreSpeaks Jul 29, 2026
5e5fd7f
merge: staging into feat/table-views
TheodoreSpeaks Jul 29, 2026
c6acc62
merge: staging into feat/table-views; route layout by owner record, k…
TheodoreSpeaks Jul 29, 2026
adc557a
merge: staging into feat/table-views
TheodoreSpeaks Jul 29, 2026
7b1af41
merge: staging into feat/table-views; grid stamps layout writes with …
TheodoreSpeaks Jul 29, 2026
14c2681
ci: raise Build App timeout to 25 minutes
TheodoreSpeaks Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,11 @@ jobs:
build:
name: Build App
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-16vcpu-ubuntu-2404' || 'linux-x64-8-core' }}
timeout-minutes: 15
# Build durations crossed 15 minutes as the app grew (10m02 on Jul 29 AM,
# 14m44 after the folders/desktop/library merges, then two straight
# timeouts) — GitHub reports a job timeout as "cancelled". 25 keeps
# headroom without masking a genuine hang.
timeout-minutes: 25

steps:
- name: Checkout code
Expand Down
106 changes: 106 additions & 0 deletions apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { deleteTableViewContract, updateTableViewContract } from '@/lib/api/contracts/tables'
import { parseRequest, validationErrorResponse } from '@/lib/api/server/validation'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { TableSchema } from '@/lib/table'
import { deleteTableView, TableViewValidationError, updateTableView } from '@/lib/table'
import { accessError, checkAccess } from '@/app/api/table/utils'

const logger = createLogger('TableViewAPI')

interface TableViewRouteParams {
params: Promise<{ tableId: string; viewId: string }>
}

/** PATCH /api/table/[tableId]/views/[viewId] - Rename, overwrite config, or set as default. */
export const PATCH = withRouteHandler(
async (request: NextRequest, context: TableViewRouteParams) => {
const requestId = generateRequestId()

try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}

const parsed = await parseRequest(updateTableViewContract, request, context, {
validationErrorResponse: (error) => validationErrorResponse(error),
})
if (!parsed.success) return parsed.response

const { tableId, viewId } = parsed.data.params
const { workspaceId, name, config, configPatch, isDefault } = parsed.data.body

const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)

if (result.table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

const columns = (result.table.schema as TableSchema).columns ?? []
const view = await updateTableView({
viewId,
tableId,
name,
config,
configPatch,
isDefault,
columns,
})
if (!view) {
return NextResponse.json({ error: 'View not found' }, { status: 404 })
}

return NextResponse.json({ success: true, data: { view } })
} catch (error) {
if (error instanceof TableViewValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error updating table view:`, error)
return NextResponse.json({ error: 'Failed to update view' }, { status: 500 })
}
}
)

/** DELETE /api/table/[tableId]/views/[viewId] - Remove a saved view. */
export const DELETE = withRouteHandler(
async (request: NextRequest, context: TableViewRouteParams) => {
const requestId = generateRequestId()

try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}

const parsed = await parseRequest(deleteTableViewContract, request, context, {
validationErrorResponse: (error) => validationErrorResponse(error),
})
if (!parsed.success) return parsed.response

const { tableId, viewId } = parsed.data.params
const { workspaceId } = parsed.data.body

const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)

if (result.table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

const deleted = await deleteTableView(viewId, tableId)
if (!deleted) {
return NextResponse.json({ error: 'View not found' }, { status: 404 })
}

return NextResponse.json({ success: true, data: { deleted: true } })
} catch (error) {
logger.error(`[${requestId}] Error deleting table view:`, error)
return NextResponse.json({ error: 'Failed to delete view' }, { status: 500 })
}
}
)
96 changes: 96 additions & 0 deletions apps/sim/app/api/table/[tableId]/views/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { createTableViewContract, listTableViewsContract } from '@/lib/api/contracts/tables'
import { parseRequest, validationErrorResponse } from '@/lib/api/server/validation'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { TableSchema } from '@/lib/table'
import { createTableView, listTableViews, TableViewValidationError } from '@/lib/table'
import { accessError, checkAccess } from '@/app/api/table/utils'

const logger = createLogger('TableViewsAPI')

interface TableRouteParams {
params: Promise<{ tableId: string }>
}

/** GET /api/table/[tableId]/views - List every saved view on a table. */
export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
const requestId = generateRequestId()

try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}

const parsed = await parseRequest(listTableViewsContract, request, context, {
validationErrorResponse: (error) => validationErrorResponse(error),
})
if (!parsed.success) return parsed.response

const { tableId } = parsed.data.params
const { workspaceId } = parsed.data.query

const result = await checkAccess(tableId, authResult.userId, 'read')
if (!result.ok) return accessError(result, requestId, tableId)

if (result.table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

const columns = (result.table.schema as TableSchema).columns ?? []
const views = await listTableViews(tableId, columns)

return NextResponse.json({ success: true, data: { views } })
} catch (error) {
logger.error(`[${requestId}] Error listing table views:`, error)
return NextResponse.json({ error: 'Failed to list views' }, { status: 500 })
}
})

/** POST /api/table/[tableId]/views - Save the current filter/sort/layout as a named view. */
export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
const requestId = generateRequestId()

try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}

const parsed = await parseRequest(createTableViewContract, request, context, {
validationErrorResponse: (error) => validationErrorResponse(error),
})
if (!parsed.success) return parsed.response

const { tableId } = parsed.data.params
const { workspaceId, name, config } = parsed.data.body

const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)

if (result.table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

const columns = (result.table.schema as TableSchema).columns ?? []
const view = await createTableView({
tableId,
workspaceId,
name,
config,
userId: authResult.userId,
columns,
})

return NextResponse.json({ success: true, data: { view } })
} catch (error) {
if (error instanceof TableViewValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error creating table view:`, error)
return NextResponse.json({ error: 'Failed to create view' }, { status: 500 })
}
})
8 changes: 6 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Metadata } from 'next'
import { getSession } from '@/lib/auth'
import { Home } from '@/app/workspace/[workspaceId]/home/home'
import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
import { resolveTableViewsEnabled } from '@/app/workspace/[workspaceId]/home/resolve-table-views-flag'

export const metadata: Metadata = {
title: 'Chat',
Expand All @@ -16,14 +17,17 @@ interface ChatPageProps {
}

export default async function ChatPage({ params }: ChatPageProps) {
const [{ chatId }, session] = await Promise.all([params, getSession()])
const [{ workspaceId, chatId }, session] = await Promise.all([params, getSession()])
const userId = session?.user?.id
const tableViewsEnabled = await resolveTableViewsEnabled(workspaceId, userId)
return (
<Suspense fallback={<HomeFallback />}>
<Home
key={chatId}
chatId={chatId}
userName={session?.user?.name}
userId={session?.user?.id}
userId={userId}
tableViewsEnabled={tableViewsEnabled}
/>
</Suspense>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ interface ResourceOptionsProps {
* widgets; primary actions belong in the header's `actions`.
*/
aside?: ReactNode
/**
* Mirror of {@link aside} on the other side: rendered immediately to the RIGHT
* of the filter/sort cluster and still grouped with it — e.g. the table editor's
* Columns menu, which reads as the last item in the Filter/Sort/Columns row.
*/
asideEnd?: ReactNode
/**
* Control pinned to the far RIGHT of the bar, opposite the filter/sort cluster —
* e.g. the table editor's Save-view button. Unlike `aside` it is a real action,
* so it is separated from the menu group rather than joined to it.
*/
trailing?: ReactNode
}

export const ResourceOptions = memo(function ResourceOptions({
Expand All @@ -99,6 +111,8 @@ export const ResourceOptions = memo(function ResourceOptions({
filter,
filterTags,
aside,
asideEnd,
trailing,
}: ResourceOptionsProps) {
/**
* Coordinates the Filter popover and Sort menu as a single menu bar: clicking
Expand All @@ -111,14 +125,23 @@ export const ResourceOptions = memo(function ResourceOptions({
const isToggleFilter = filter?.mode === 'toggle'
const popoverFilter = filter && filter.mode !== 'toggle' ? filter : null

const hasContent = search || sort || filter || aside || (filterTags && filterTags.length > 0)
const hasContent =
search ||
sort ||
filter ||
aside ||
asideEnd ||
trailing ||
(filterTags && filterTags.length > 0)
if (!hasContent) return null

return (
<div className={cn('border-[var(--border)] border-b py-2.5', search ? 'px-6' : 'px-4')}>
<div className='flex items-center'>
{search && <SearchSection search={search} />}
<div className={cn('flex shrink-0 items-center gap-1.5', search && 'ml-auto')}>
{/* `ml-auto` moves to `trailing` when present so the menu cluster stays put
and only the trailing action is pushed to the far edge. */}
<div className={cn('flex shrink-0 items-center gap-1.5', search && !trailing && 'ml-auto')}>
{aside}
<div className='flex items-center'>
{filterTags?.map((tag) => (
Expand Down Expand Up @@ -177,7 +200,9 @@ export const ResourceOptions = memo(function ResourceOptions({
) : null}
{sort && (isToggleFilter || !popoverFilter) && <SortDropdown config={sort} />}
</div>
{asideEnd}
</div>
{trailing && <div className='ml-auto flex shrink-0 items-center gap-1.5'>{trailing}</div>}
</div>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ interface ResourceContentProps {
isAgentResponding?: boolean
genericResourceData?: GenericResourceData
previewContextKey?: string
/** Resolved server-side by the home page — the embedded table can't read
* AppConfig itself, so the flag is threaded down rather than looked up. */
tableViewsEnabled?: boolean
onNotFound?: (resourceId: string) => void
/**
* Whether this resource is the one on screen. Only the persistent panels
Expand Down Expand Up @@ -153,6 +156,7 @@ export const ResourceContent = memo(function ResourceContent({
isAgentResponding,
genericResourceData,
previewContextKey,
tableViewsEnabled,
onNotFound,
visible = true,
}: ResourceContentProps) {
Expand Down Expand Up @@ -223,7 +227,15 @@ export const ResourceContent = memo(function ResourceContent({

switch (resource.type) {
case 'table':
return <Table key={resource.id} workspaceId={workspaceId} tableId={resource.id} embedded />
return (
<Table
key={resource.id}
workspaceId={workspaceId}
tableId={resource.id}
embedded
viewsEnabled={tableViewsEnabled}
/>
)

case 'file':
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ interface MothershipViewProps {
previewSession?: FilePreviewSession | null
isAgentResponding?: boolean
genericResourceData?: GenericResourceData
/** Resolved server-side by the home page; forwarded to the embedded table. */
tableViewsEnabled?: boolean
}

export const MothershipView = memo(
Expand All @@ -81,6 +83,7 @@ export const MothershipView = memo(
previewSession,
isAgentResponding,
genericResourceData,
tableViewsEnabled,
}: MothershipViewProps,
ref
) {
Expand Down Expand Up @@ -189,6 +192,7 @@ export const MothershipView = memo(
isAgentResponding={isAgentResponding}
genericResourceData={active.type === 'generic' ? genericResourceData : undefined}
previewContextKey={chatId}
tableViewsEnabled={tableViewsEnabled}
onNotFound={(resourceId) => removeResource('log', resourceId)}
/>
)}
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/home/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,11 @@ interface HomeProps {
chatId?: string
userName?: string
userId?: string
/** Resolved server-side by the page — the embedded table can't reach AppConfig. */
tableViewsEnabled?: boolean
}

export function Home({ chatId, userName, userId }: HomeProps) {
export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) {
useOAuthReturnRouter()
const { workspaceId } = useParams<{ workspaceId: string }>()
const router = useRouter()
Expand Down Expand Up @@ -523,6 +525,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
previewSession={previewSession}
isAgentResponding={isSending}
genericResourceData={genericResourceData ?? undefined}
tableViewsEnabled={tableViewsEnabled}
className={skipResourceTransition ? '!transition-none' : undefined}
/>
</Suspense>
Expand Down
Loading
Loading