Skip to content

Commit ec7680d

Browse files
refactor(dashboards): give agent dashboard commands the files folder syntax
Dashboards take folder paths like files: create/list --folder, move --to (/ is the root), rename and set-content as separate actions, and folder create/move/delete by positional path with --recursive for non-empty deletes. The UI routes keep folder ids. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S6aTRnkiu7PxYZPNPXYEMV
1 parent f21d4ff commit ec7680d

9 files changed

Lines changed: 146 additions & 36 deletions

File tree

‎apps/sim/app/api/workspaces/[id]/dashboards/folders/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,6 @@ export const DELETE = defineInternalJsonRoute({
5454
operation: dashboardOperations.deleteFolder,
5555
rateLimit: internalRateLimits.user({ bucketName: 'dashboards' }),
5656
errorPolicy: internalOrchestrationErrorPolicy,
57-
mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }),
57+
mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body, recursive: true }),
5858
useCase: deleteDashboardFolder,
5959
})
Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,72 @@
11
import { z } from 'zod'
2+
import { dashboardContentSchema, dashboardNameSchema } from '@/lib/api/contracts/dashboards'
23
import {
3-
createDashboardBodySchema,
4-
createDashboardFolderBodySchema,
5-
moveDashboardBodySchema,
6-
moveDashboardFolderBodySchema,
7-
updateDashboardBodySchema,
8-
} from '@/lib/api/contracts/dashboards'
4+
normalizeFolderPathInput,
5+
v2FolderPathSchema,
6+
v2NonRootFolderPathSchema,
7+
} from '@/lib/api/contracts/v2/shared'
8+
9+
/**
10+
* The files folder-path inputs, rebuilt without their registry ids: an agent tool schema
11+
* must inline every property, and a registered id becomes an unsupported `$ref`.
12+
*/
13+
const folderPath = z
14+
.string()
15+
.transform(normalizeFolderPathInput)
16+
.pipe(v2FolderPathSchema)
17+
.describe('Folder path, as for files; the leading / is optional and / is the root.')
18+
const folderPathNotRoot = z
19+
.string()
20+
.transform(normalizeFolderPathInput)
21+
.pipe(v2NonRootFolderPathSchema)
22+
.describe('Folder path as shown in the app; the leading / is optional.')
923

1024
const scope = z.object({ workspaceId: z.string().min(1).max(100).optional() })
1125
const target = scope.extend({ dashboardId: z.string().min(1).max(100) })
26+
/** Mirrors the `files` commands: folders are addressed by path (leading `/` optional), and `/` is the root. */
1227
export const mothershipDashboardsInputSchema = z.discriminatedUnion('action', [
13-
scope.extend({ action: z.literal('list'), search: z.string().max(255).optional() }).strict(),
28+
scope
29+
.extend({
30+
action: z.literal('list'),
31+
search: z.string().max(255).optional(),
32+
folder: folderPath.optional(),
33+
})
34+
.strict(),
1435
target.extend({ action: z.literal('get') }).strict(),
15-
scope.extend({ ...createDashboardBodySchema.shape, action: z.literal('create') }).strict(),
16-
target.extend({ ...updateDashboardBodySchema.shape, action: z.literal('update') }).strict(),
17-
target.extend({ ...moveDashboardBodySchema.shape, action: z.literal('move') }).strict(),
36+
scope
37+
.extend({
38+
action: z.literal('create'),
39+
name: dashboardNameSchema,
40+
content: dashboardContentSchema,
41+
folder: folderPath.optional(),
42+
})
43+
.strict(),
44+
target
45+
.extend({
46+
action: z.literal('set-content'),
47+
content: dashboardContentSchema,
48+
expectedRevision: z.string().min(1).max(256),
49+
})
50+
.strict(),
51+
target.extend({ action: z.literal('rename'), name: dashboardNameSchema }).strict(),
52+
target.extend({ action: z.literal('move'), to: folderPath }).strict(),
1853
target.extend({ action: z.literal('delete') }).strict(),
1954
])
2055
export const mothershipDashboardFoldersInputSchema = z.discriminatedUnion('action', [
2156
scope.extend({ action: z.literal('list') }).strict(),
22-
scope.extend({ ...createDashboardFolderBodySchema.shape, action: z.literal('create') }).strict(),
23-
scope.extend({ ...moveDashboardFolderBodySchema.shape, action: z.literal('move') }).strict(),
24-
scope.extend({ ...createDashboardFolderBodySchema.shape, action: z.literal('delete') }).strict(),
57+
scope.extend({ action: z.literal('create'), path: folderPathNotRoot }).strict(),
58+
scope
59+
.extend({
60+
action: z.literal('move'),
61+
path: folderPathNotRoot,
62+
destination: folderPathNotRoot,
63+
})
64+
.strict(),
65+
scope
66+
.extend({
67+
action: z.literal('delete'),
68+
path: folderPathNotRoot,
69+
recursive: z.boolean().optional(),
70+
})
71+
.strict(),
2572
])

‎apps/sim/lib/api/contracts/mothership-management-tools.test.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,24 @@ const examples = {
3333
{ action: 'approve', connectorType: 'google_drive', approved: true },
3434
],
3535
dashboards: [
36-
{ action: 'list', search: 'support' },
36+
{ action: 'list', search: 'support', folder: '/Operations' },
3737
{ action: 'get', dashboardId: 'dash-1' },
38-
{ action: 'create', name: 'Support', content: 'title: Support\nblocks: []', folderId: null },
39-
{ action: 'update', dashboardId: 'dash-1', content: 'title: Support', expectedRevision: 'r1' },
40-
{ action: 'move', dashboardId: 'dash-1', folderId: null },
38+
{ action: 'create', name: 'Support', content: 'title: Support\nblocks: []', folder: '/' },
39+
{
40+
action: 'set-content',
41+
dashboardId: 'dash-1',
42+
content: 'title: Support',
43+
expectedRevision: 'r1',
44+
},
45+
{ action: 'rename', dashboardId: 'dash-1', name: 'Support' },
46+
{ action: 'move', dashboardId: 'dash-1', to: '/' },
4147
{ action: 'delete', dashboardId: 'dash-1' },
4248
],
4349
dashboard_folders: [
4450
{ action: 'list' },
4551
{ action: 'create', path: '/Operations' },
46-
{ action: 'move', path: '/Operations', destinationPath: '/Ops' },
47-
{ action: 'delete', path: '/Operations' },
52+
{ action: 'move', path: '/Operations', destination: '/Ops' },
53+
{ action: 'delete', path: '/Operations', recursive: true },
4854
],
4955
}
5056

‎apps/sim/lib/api/contracts/mothership-management-tools.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ export const managementToolContracts = [
1818
route: 'sim',
1919
scope: 'all',
2020
description:
21-
'Create, list, read, edit, move, rename and delete dashboards in the selected workspace. Dashboards are separate resources with validated YAML source. Load the create-dashboard skill for the actual schema. get returns content and revision; update requires expectedRevision to avoid overwriting concurrent edits. move changes name and/or folderId. Use open_resource with type dashboard to show the result. Sharing is not supported by this tool.',
21+
'Create, list, read, edit, move, rename and delete dashboards in the selected workspace, with the same folder paths as files: `/` is the root. Dashboards are separate resources with validated YAML source. Load the create-dashboard skill for the actual schema. get returns content and revision; set-content requires expectedRevision to avoid overwriting concurrent edits. Use open_resource with type dashboard to show the result. Sharing is not supported by this tool.',
2222
inputSchema: mothershipDashboardsInputSchema,
2323
},
2424
{
2525
id: 'dashboard_folders',
2626
route: 'sim',
2727
scope: 'all',
2828
description:
29-
'Manage the selected workspace’s dashboard folders. Paths are slash-separated under the Dashboards root. Create requires existing ancestors; move also renames via destinationPath. Delete archives the folder and its dashboards recursively. Folders are separate from Files.',
29+
'Manage the selected workspace’s dashboard folders with the same paths as file folders. Create requires existing ancestors; move also renames via the destination path. Delete of a non-empty folder requires recursive, which archives the folder and its dashboards. Folders are separate from Files.',
3030
inputSchema: mothershipDashboardFoldersInputSchema,
3131
},
3232
{

‎apps/sim/lib/api/contracts/v2/shared.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,8 @@ export const v2NonRootFolderPathSchema = canonicalFolderPathSchema(requireNonRoo
544544
maxLength: MAX_FOLDER_PATH_BYTES,
545545
})
546546

547-
function normalizeFolderPathInput(path: string): string {
547+
/** Adds the leading slash a folder path may omit; validation stays with the canonical schemas. */
548+
export function normalizeFolderPathInput(path: string): string {
548549
return path.length === 0 || path.startsWith('/') ? path : `/${path}`
549550
}
550551

‎apps/sim/lib/dashboards/application/dashboards.ts‎

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import { dashboardOperations } from '@/lib/dashboards/application/operations'
55
import { requireDashboardsEnabled } from '@/lib/dashboards/feature-flag'
66
import { DASHBOARD_CONTENT_TYPE, dashboardDisplayName } from '@/lib/dashboards/resource'
77
import { MAX_DASHBOARD_SOURCE_BYTES, parseDashboardSpec } from '@/lib/dashboards/spec'
8+
import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants'
9+
import {
10+
loadActiveFolderPathIndex,
11+
resolveFolderPathFilter,
12+
resolveFolderPathFromIndex,
13+
} from '@/lib/folders/queries'
814
import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify'
915
import {
1016
ContentVersionConflictError,
@@ -65,6 +71,16 @@ export function dashboardRecord(file: WorkspaceFileRecord) {
6571
}
6672
}
6773

74+
/** A mutation into a path that names no dashboard folder fails; `/` is the root. */
75+
async function resolveDashboardFolder(workspaceId: string, path: string): Promise<string | null> {
76+
const index = await loadActiveFolderPathIndex(workspaceId, 'dashboard', undefined, {
77+
maxRows: MAX_FOLDERS_PER_WORKSPACE,
78+
})
79+
const folderId = resolveFolderPathFromIndex(index, path)
80+
if (folderId === undefined) throw new OrchestrationError('not_found', 'Target folder not found')
81+
return folderId
82+
}
83+
6884
function validateContent(content: string) {
6985
const parsed = parseDashboardSpec(content)
7086
if (parsed.error) throw new OrchestrationError('validation', parsed.error)
@@ -86,15 +102,28 @@ export const listDashboards = defineAuthorizedWorkspaceFileUseCase({
86102
resolveContext: ({
87103
input,
88104
}: {
89-
input: { workspaceId: string; search?: string; folderId?: string | null }
105+
input: { workspaceId: string; search?: string; folderId?: string | null; folderPath?: string }
90106
}) => dashboardWorkspace(input.workspaceId),
91107
authorizeResource: ({ context }) => requireDashboardsEnabled(context.workspaceOrganizationId),
92108
async execute({ input, context }) {
109+
if (input.folderId !== undefined && input.folderPath !== undefined)
110+
throw new OrchestrationError('validation', 'Specify either folderId or folderPath, not both')
111+
let folderId = input.folderId
112+
if (input.folderPath !== undefined) {
113+
const filter = resolveFolderPathFilter(
114+
await loadActiveFolderPathIndex(context.workspaceId, 'dashboard', undefined, {
115+
maxRows: MAX_FOLDERS_PER_WORKSPACE,
116+
}),
117+
input.folderPath
118+
)
119+
if (filter.kind === 'noMatch') return { dashboards: [], truncated: false }
120+
if (filter.kind === 'folder') folderId = filter.folderId
121+
}
93122
const { files, nextKeys } = await queryWorkspaceFiles(context.workspaceId, {
94123
discovery: 'unlisted',
95124
contentType: DASHBOARD_CONTENT_TYPE,
96125
search: input.search,
97-
folderId: input.folderId,
126+
folderId,
98127
sortBy: 'name',
99128
sortOrder: 'asc',
100129
limit: 500,
@@ -119,7 +148,13 @@ export const createDashboard = defineAuthorizedWorkspaceFileUseCase({
119148
resolveContext: ({
120149
input,
121150
}: {
122-
input: { workspaceId: string; name: string; content: string; folderId?: string | null }
151+
input: {
152+
workspaceId: string
153+
name: string
154+
content: string
155+
folderId?: string | null
156+
folderPath?: string
157+
}
123158
}) => dashboardWorkspace(input.workspaceId),
124159
authorizeResource: ({ context }) => requireDashboardsEnabled(context.workspaceOrganizationId),
125160
async execute({ input, context, principal }) {
@@ -132,6 +167,7 @@ export const createDashboard = defineAuthorizedWorkspaceFileUseCase({
132167
DASHBOARD_CONTENT_TYPE,
133168
{
134169
folderId: input.folderId,
170+
folderPath: input.folderPath,
135171
exactName: true,
136172
discovery: 'unlisted',
137173
secretProvenance: EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE,
@@ -199,16 +235,27 @@ export const moveDashboard = defineAuthorizedWorkspaceFileUseCase({
199235
resolveContext: ({
200236
input,
201237
}: {
202-
input: DashboardTarget & { name?: string; folderId?: string | null }
238+
input: DashboardTarget & { name?: string; folderId?: string | null; targetFolderPath?: string }
203239
}) => dashboardContext(input),
204240
authorizeResource: ({ context }) => requireDashboardsEnabled(context.workspaceOrganizationId),
205241
async execute({ input, context }) {
242+
if (input.folderId !== undefined && input.targetFolderPath !== undefined)
243+
throw new OrchestrationError(
244+
'validation',
245+
'Specify either folderId or targetFolderPath, not both'
246+
)
206247
const file = await requireDashboardFile(context.workspaceId, context.fileId)
248+
const targetFolderId =
249+
input.targetFolderPath !== undefined
250+
? await resolveDashboardFolder(context.workspaceId, input.targetFolderPath)
251+
: input.folderId === undefined
252+
? (file.folderId ?? null)
253+
: input.folderId
207254
const result = await moveRenameWorkspaceFile({
208255
workspaceId: context.workspaceId,
209256
fileId: context.fileId,
210257
newName: input.name === undefined ? file.name : storageName(input.name),
211-
targetFolderId: input.folderId === undefined ? (file.folderId ?? null) : input.folderId,
258+
targetFolderId,
212259
})
213260
return { dashboard: dashboardRecord(result.file), changed: result.renamed || result.moved }
214261
},

‎apps/sim/lib/dashboards/application/folders.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,16 @@ export const moveDashboardFolder = defineAuthorizedWorkspaceFileUseCase({
9898

9999
export const deleteDashboardFolder = defineAuthorizedWorkspaceFileUseCase({
100100
operation: dashboardOperations.deleteFolder,
101-
resolveContext: ({ input }: { input: FolderTarget }) => dashboardWorkspace(input.workspaceId),
101+
resolveContext: ({ input }: { input: FolderTarget & { recursive: boolean } }) =>
102+
dashboardWorkspace(input.workspaceId),
102103
authorizeResource: ({ context }) => requireDashboardsEnabled(context.workspaceOrganizationId),
103104
async execute({ context, input, principal }) {
104105
const result = await deleteFolderByPathTransition({
105106
workspaceId: context.workspaceId,
106107
resourceType: 'dashboard',
107108
userId: requirePrincipalSubjectUserId(principal),
108109
path: input.path,
109-
recursive: true,
110+
recursive: input.recursive,
110111
maxFolderRows: MAX_FOLDERS_PER_WORKSPACE,
111112
effects: false,
112113
throwInfrastructure: true,

‎apps/sim/lib/dashboards/authoring.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/** The built-in authoring skill ships alongside the parser, so models can learn the actual format. */
22
export const DASHBOARD_AUTHORING_CONTENT = `# Create a dashboard
33
4-
A dashboard is YAML over live table rows. Inspect the table's columns first, then call dashboards.create with name and content. dashboards.get returns content and revision; dashboards.update needs expectedRevision. Open it with open_resource (type dashboard). Folders are dashboard_folders. Never write HTML, JS, CSS, or a custom chart grammar; log queries are not supported.
4+
A dashboard is YAML over live table rows. Inspect the table's columns first, then run \`dashboards create --name --content\` (add \`--folder <path>\` for a folder). \`dashboards get\` returns content and revision; \`dashboards set-content\` needs \`--expected-revision\`. Open it with open_resource (type dashboard). Folders work like file folders: \`dashboards folders create|move|delete <path>\`. Never write HTML, JS, CSS, or a custom chart grammar; log queries are not supported.
55
66
\`\`\`yaml
77
title: Support

‎apps/sim/lib/mothership/tools/server/dashboards.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export const dashboardsServerTool: BaseServerTool = {
5555
return executeDashboardUseCase(context, listDashboards, {
5656
workspaceId,
5757
search: input.search,
58+
folderPath: input.folder,
5859
})
5960
case 'get':
6061
return executeDashboardUseCase(context, readDashboard, {
@@ -63,22 +64,28 @@ export const dashboardsServerTool: BaseServerTool = {
6364
})
6465
case 'create': {
6566
const result = await executeDashboardUseCase(context, createDashboard, {
66-
...input,
6767
workspaceId,
68+
name: input.name,
69+
content: input.content,
70+
folderPath: input.folder,
6871
})
6972
return { ...result, resources: dashboardEffect(workspaceId, result.dashboard) }
7073
}
71-
case 'update': {
74+
case 'set-content': {
7275
const result = await executeDashboardUseCase(context, updateDashboard, {
73-
...input,
7476
workspaceId,
77+
dashboardId: input.dashboardId,
78+
content: input.content,
79+
expectedRevision: input.expectedRevision,
7580
})
7681
return { ...result, resources: dashboardEffect(workspaceId, result.dashboard) }
7782
}
83+
case 'rename':
7884
case 'move': {
7985
const result = await executeDashboardUseCase(context, moveDashboard, {
80-
...input,
8186
workspaceId,
87+
dashboardId: input.dashboardId,
88+
...(input.action === 'rename' ? { name: input.name } : { targetFolderPath: input.to }),
8289
})
8390
return { ...result, resources: dashboardEffect(workspaceId, result.dashboard) }
8491
}
@@ -114,12 +121,13 @@ export const dashboardFoldersServerTool: BaseServerTool = {
114121
return executeDashboardUseCase(context, moveDashboardFolder, {
115122
workspaceId,
116123
path: input.path,
117-
destinationPath: input.destinationPath,
124+
destinationPath: input.destination,
118125
})
119126
case 'delete':
120127
return executeDashboardUseCase(context, deleteDashboardFolder, {
121128
workspaceId,
122129
path: input.path,
130+
recursive: input.recursive ?? false,
123131
})
124132
}
125133
},

0 commit comments

Comments
 (0)