feat(ai): dashboard & view building (#22411)

## Why

Building a dashboard through AI chat used to cost ~9 sequential LLM
round-trips
(~160K input tokens for a single request): the agent had to resolve
object/field UUIDs and assemble views through many granular tool calls,
each
step replaying the full cached context.

## What changed

### 1. Reference objects & fields by name (fewer round-trips)
The agent no longer needs to resolve UUIDs before acting.
- `get_object_metadata`: filter by `objectName` (singular/plural) and a
new
`includeFields` flag returning each object's fields (`{id, name, type,
label}`)
  inline — object + field IDs in one call.
- `get_field_metadata`: accepts `objectName` as an alternative to
  `objectMetadataId`.
- All three dashboard write tools (`create_complete_dashboard`,
`add_dashboard_widget`, `update_dashboard_widget`): accept `objectName`
and
`*FieldName` variants (`aggregateFieldName`,
`primaryAxisGroupByFieldName`,
`secondaryAxisGroupByFieldName`, `groupByFieldName`, ratio `fieldName`),
resolved to UUIDs server-side by `resolveWidgetFieldNamesToIds`. UUID
variants
  still win when both are given.

### 2. `upsert_complete_view` — one atomic call to build/reconfigure a
view
- New `upsert_complete_view` tool + `ViewService.upsertCompleteView`:
create or
  update a view together with its fields, filters, and sorts.
- Children are **declarative**: a provided array replaces all existing
entries of
that kind, `[]` clears them, omitting leaves them untouched. Fields are
  referenced by name or UUID; no child-row IDs needed.
- Runs as a **single workspace migration** (`view` + `viewField` +
`viewFilter` +
`viewSort` in one `validateBuildAndRunWorkspaceMigration` matrice)
instead of
chained per-entity service calls. New
`buildCompleteViewChildrenFlatOperations`
  util assembles the child create/delete operations.
- Granular tools (`create_view_filter`, `update_view_sort`, …) are
retained for
  surgical single-entry edits.

### 3. Chart filters on dashboard widgets (end-to-end)
- Added `chartFilterSchema` (`recordFilters` + optional
`recordFilterGroups` for
AND/OR logic) to the four chart configs, with field-by-name or -UUID
references
  and documented operands/value formats.
- **Relative dates supported** — e.g. `PAST_7_DAY`, `THIS_1_MONTH`,
`NEXT_3_WEEK`,
plus open-ended `IS_IN_PAST` / `IS_IN_FUTURE` / `IS_TODAY`. Filters
route
through the same read pipeline (`computeRecordGqlOperationFilter`) as
view
  filters, so they resolve and apply correctly.
- `resolveChartFilterFieldNamesToIds` resolves filter `fieldName` → id
against the
  widget object.

### 4. Re-enable AI-assisted dashboards
- Removed the "coming soon" gating (`isActive: false` on the dashboard
skill and
the "not available yet" copy in the MCP server + chat prompts) and
registered
  `DashboardToolProvider`.
- Rewrote the dashboard skill prompt: confirmation gate (present a plan,
wait for
confirmation), completion guard (once confirmed, emit the create tool
in-turn —
no "now let me…" preambles), default-and-proceed (pick sensible defaults
for
missing fields instead of stalling), and an intent gate so informational
  dashboard questions are answered directly without loading skills.

### 5. Frontend: clearer advanced-filter labels
- `useRecordFilterField` now derives the filter label from field
metadata and
appends the relation target field (e.g. `Company → Name`), so
relation/target
filters — including those set by the AI — display correctly instead of
showing
  a stale/blank stored label.

## Fixes
- **`get_object_metadata({ objectName })` crash.**
`ObjectMetadataService.findManyWithinWorkspace`
  spread an array-form (`OR`) `where` into a plain object, producing
`{ "0": {...}, "1": {...}, workspaceId }` → `Property "0" was not found
in
"ObjectMetadataEntity"`. Now injects `workspaceId` into each OR clause,
so name
  lookups work.
- **Invalid SELECT/MULTI_SELECT filter options silently produced broken
charts/views.**
Chart-configuration validation and the migration-layer
`FlatViewFilterValidator`
now reject filters that reference options that don't exist, with a clear
  `Allowed values: …` message at creation time (shared
  `getInvalidSelectFilterOptionValues` util + tests).
- **Non-atomic view assembly.** The previous multi-call view build could
leave a
half-built view on failure; `upsert_complete_view` now runs as a single
transaction (one validation pass, one cache recompute, rollback on
error).
- **Blank RECORD_TABLE widgets from UNLISTED views.** Guidance + the
upsert
ownership check steer widget-backing views to `WORKSPACE` visibility; an
  UNLISTED view created without an owner renders a blank widget.
- **Extra discovery round-trip removed.** Deleted the skill→tool bundle
mechanism
(`SKILL_TOOL_BUNDLES`, `getBundledToolNamesForSkills`, and the
`load_skills`
  schema-loading path) that forced a second `learn_tools` call.
- **Type-safety of widget resolution.** Reworked the widget resolver to
build a
properly typed `WidgetWithMetadataIds` (dedicated input/output types)
instead of
  returning an untyped, cast-heavy object.

## Notes
- Backend changes are in `twenty-server`; one small `twenty-front`
change to the
  advanced-filter label hook. No entity/schema changes, so no migration.
- Tests added: `getInvalidSelectFilterOptionValues`,
`resolveWidgetFieldNamesToIds`
(incl. filter/relative-date resolution), `update_dashboard_widget`, and
expanded
  view-tools factory specs.
- Design decisions: dedicated composite tool over code-interpreter
orchestration
(atomicity + validation + consistency with `create_complete_dashboard` /
  `create_complete_workflow`); name-or-UUID but no child-row IDs on
`upsert_complete_view`; name→id resolution kept as stateless utils, not
services.

## Test plan
- [ ] `npx nx run twenty-server:typecheck`
- [ ] `npx nx lint:diff-with-main twenty-server` and `twenty-front`
- [ ] `npx nx test twenty-server` (view tools factory,
`getInvalidSelectFilterOptionValues`,
      `resolveWidgetFieldNamesToIds`, `update_dashboard_widget`)
- [ ] AI chat: "Create a dashboard with a chart of deal value by
pipeline stage
      and a table of the top 10 open opportunities" → plans, waits for
      confirmation, then builds with fewer round-trips
- [ ] AI chat: add a chart widget filtered by a relative date (e.g.
deals created
      in `PAST_7_DAY`) and confirm the chart is actually filtered
- [ ] Filter on a non-existent SELECT option is rejected with a clear
error

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22411?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Etienne
2026-07-03 16:15:27 +02:00
committed by GitHub
parent 1a85b88d38
commit 1270054d35
40 changed files with 3013 additions and 162 deletions
@@ -425,14 +425,38 @@ You help users create and manage dashboards with widgets.
- add_dashboard_tab, add_dashboard_widget, update_dashboard_widget, delete_dashboard_widget
- get_object_metadata / get_field_metadata (resolve object + field IDs)
## Graph Widget Workflow
## Confirmation gate (ALWAYS ask before creating or updating)
1. Ask what data the user wants to visualize.
2. Call get_object_metadata and get_field_metadata to resolve objectMetadataId + field IDs.
3. Always call get_dashboard before modifying widgets.
4. Build the widget configuration using the rules below.
5. Call add_dashboard_widget or update_dashboard_widget. Use activeTabId from context if available.
6. Call get_dashboard to verify the final configuration.
Before calling ANY tool that creates or modifies a dashboard (\`create_complete_dashboard\`, \`add_dashboard_tab\`, \`add_dashboard_widget\`, \`update_dashboard_widget\`, \`delete_dashboard_widget\`), you MUST first present a short plan and get explicit user confirmation.
- Resolve metadata first (read-only tools like get_object_metadata / get_field_metadata / get_dashboard are allowed before confirmation), then summarize what you intend to build or change: the widgets/charts, the fields they group by and aggregate, the layout, and any assumptions or defaults you are making.
- Ask the user to confirm (or adjust) and then STOP and wait for their answer. Do NOT call any creation/update tool in the same turn as the plan.
- Only after the user confirms do you proceed to build/modify in the next turn.
- Keep the plan concise — a few bullets, not an essay. The goal is a quick "yes, go ahead" or a correction, not a lengthy back-and-forth.
## Build Workflow (creating a new dashboard, AFTER confirmation)
Once the user has confirmed the plan, your job is to deliver the dashboard in that turn.
1. Resolve metadata in as few calls as possible: call get_object_metadata and get_field_metadata for the relevant object(s) once, and batch field lookups. Do NOT re-fetch metadata you already have in context.
2. Build the full widget configuration using the rules below.
3. **Emit \`create_complete_dashboard\` with ALL widgets in a single call.** Prefer one-shot \`create_complete_dashboard\` over building the dashboard incrementally with multiple \`add_dashboard_widget\` calls — fewer round-trips means lower cost and fewer points to stall.
4. After the tool returns success, confirm to the user what was built (and restate any assumptions you made).
### Completion guard (critical, applies once confirmed)
- After the user has confirmed, you MUST call the appropriate dashboard tool for the requested change in that turn (e.g. \`create_complete_dashboard\`, \`add_dashboard_widget\`, \`update_dashboard_widget\`, or \`delete_dashboard_widget\`). **Never end your turn on a "now let me…" / "I'll build this…" preamble without actually calling the tool.** A preamble with no following tool call (after confirmation) is a failure.
- Do NOT yield or hand back to the user until at least one dashboard tool has returned success — unless you are still waiting on confirmation or genuinely blocked on something only the user can answer.
### Default-and-proceed (resolve defaults in the plan, do not stall)
- If the request references a field or concept that does not exist in the workspace (e.g. "Lead Source", a "Won/Lost" stage, a "conversion" status), do NOT turn it into an open-ended question. Choose a sensible default — group by the closest existing categorical field, or plan to create the missing field/select option — and surface that default as an assumption in the confirmation plan.
- Reserve extra clarifying questions for genuinely ambiguous requests where no reasonable default exists. Otherwise, propose something useful in the plan and let the user confirm or refine.
## Modifying an existing dashboard
- Call get_dashboard first to read the current layout, then present the intended changes and get confirmation (see the confirmation gate above) before calling add_dashboard_widget / update_dashboard_widget / delete_dashboard_widget. Use activeTabId from context if available.
- Only call get_dashboard when modifying — never before creating a brand-new dashboard.
## Field Resolution Rules
@@ -483,13 +507,15 @@ You help users create and manage dashboards with widgets.
- IMPORTANT: Put the actual text content in configuration.body.markdown, NOT in the widget title
- Widget title should be a short label (e.g. "Notes", "Summary"), body.markdown holds the real content
- RECORD_TABLE: configurationType "RECORD_TABLE" — displays a filterable, sortable record list
- **MANDATORY 3-step pre-sequence before creating the widget**:
1. call create_view (type TABLE, name e.g. "Repairs Dashboard Table") → get the new viewId
2. call create_many_view_fields on the new viewId — add 46 of the most relevant fields (label identifier + key SELECT/DATE/CURRENCY fields). Use positions 0, 1, 2… and isVisible: true.
3. call create_many_view_filters and/or create_view_sort on the new viewId to focus the table (e.g. filter out DONE/CANCELLED records, sort by createdAt DESC or a date field ASC)
- **MANDATORY: create the dedicated view in ONE call before creating the widget**:
- Call \`upsert_complete_view\` once with the view plus its fields, filters, and sorts. Do NOT use the separate create_view / create_many_view_fields / create_many_view_filters / create_view_sort calls — that is several round-trips where one suffices.
- Reference fields by NAME (fieldName) — you generally do not need field UUIDs. Pass fieldMetadataId only if you already have it.
- Include 46 of the most relevant fields (label identifier + key SELECT/DATE/CURRENCY fields). Order in the array IS the column order.
- Add filters/sorts to focus the table (e.g. filter out DONE/CANCELLED records, sort by a date field).
- Never reuse a record index view — widget views and record index views must be separate
- Leave the view's visibility as WORKSPACE (the default) — never set UNLISTED on a widget-backing view, or the widget will render a blank table
- Set objectMetadataId on the widget (top-level, required)
- Set configuration.viewId to the UUID of the dedicated view (required)
- Set configuration.viewId to the UUID returned by upsert_complete_view (required)
- columnSpan 12 (full width) or 6 (half width), rowSpan 610
Example (STANDALONE_RICH_TEXT):
@@ -498,12 +524,16 @@ Example (STANDALONE_RICH_TEXT):
"body": { "markdown": "## Quarterly Summary\\n\\nKey metrics:\\n- Revenue up 15%\\n- 42 new deals closed\\n\\n**Next steps**: Focus on enterprise pipeline." }
}
Example (RECORD_TABLE — always run the 3-step pre-sequence first):
Step 1 — create_view: { "name": "Active Repairs", "objectNameSingular": "repair", "type": "TABLE" } → { "id": "<view-uuid>" }
Step 2 — create_many_view_fields: { "viewFields": [{ "viewId": "<view-uuid>", "fieldMetadataId": "<status-field-uuid>", "position": 1, "isVisible": true }, { "viewId": "<view-uuid>", "fieldMetadataId": "<amount-field-uuid>", "position": 2, "isVisible": true }] }
Step 3 — create_many_view_filters: { "filters": [{ "viewId": "<view-uuid>", "fieldMetadataId": "<status-field-uuid>", "operand": "IS_NOT", "value": "DONE" }] }
Step 3b — create_view_sort: { "viewId": "<view-uuid>", "fieldMetadataId": "<createdAt-field-uuid>", "direction": "DESC" }
Step 4 — add_dashboard_widget: { "type": "RECORD_TABLE", "objectMetadataId": "<repair-object-uuid>", "configuration": { "configurationType": "RECORD_TABLE", "viewId": "<view-uuid>" }, "gridPosition": { "row": 0, "column": 0, "rowSpan": 8, "columnSpan": 12 } }
Example (RECORD_TABLE — one view call, then the widget):
Step 1 — upsert_complete_view: {
"name": "Active Repairs",
"objectNameSingular": "repair",
"type": "TABLE",
"fields": [{ "fieldName": "name" }, { "fieldName": "status" }, { "fieldName": "amount" }],
"filters": [{ "fieldName": "status", "operand": "IS_NOT", "value": ["DONE"] }],
"sorts": [{ "fieldName": "createdAt", "direction": "DESC" }]
} → { "id": "<view-uuid>" }
Step 2 — add_dashboard_widget: { "type": "RECORD_TABLE", "objectMetadataId": "<repair-object-uuid>", "configuration": { "configurationType": "RECORD_TABLE", "viewId": "<view-uuid>" }, "gridPosition": { "row": 0, "column": 0, "rowSpan": 8, "columnSpan": 12 } }
## Tabs
@@ -528,10 +558,6 @@ After creating a tab, use its returned tabId as pageLayoutTabId when calling add
- When modifying a chart, confirm whether the user wants to change settings or change chart type
- Use RECORD_TABLE widgets to give users direct access to filtered record lists without leaving the dashboard`,
isCustom: false,
// Dashboard tools are temporarily disabled in AI chat / MCP because the
// generated dashboards are not reliable yet. Keeping the skill defined
// (inactive) so it can be re-enabled once the tooling is trustworthy.
isActive: false,
},
}),
@@ -1268,16 +1294,27 @@ You help users create and configure views to organize how they see their records
## Tools
- **upsert_complete_view** - Create OR update a view together with its fields, filters, and sorts in a single call. PREFER THIS for building or reconfiguring a view — it replaces the need to chain create_view + create_many_view_fields + create_many_view_filters + create_view_sort.
- get_views - List existing views (filter by object name)
- create_view - Create a new view
- update_view - Update view name/icon
- create_view - Create a new view (low-level; prefer upsert_complete_view)
- update_view - Update view name/icon (low-level; prefer upsert_complete_view)
- delete_view - Delete a view
- create_many_view_fields - Add visible columns to a view
- create_many_view_fields - Add visible columns to a view (low-level; prefer upsert_complete_view)
- update_many_view_fields - Update column configuration
- get_view_fields - List columns in a view
- get_object_metadata / get_field_metadata - Discover objects and their fields
- navigate_app - Navigate to a view after creation
## upsert_complete_view (preferred)
One call builds or reconfigures an entire view:
- Omit \`id\` to CREATE (requires \`objectNameSingular\`); provide \`id\` to UPDATE an existing view.
- Reference fields by NAME (\`fieldName\`) in fields/filters/sorts — they are resolved server-side, so you usually do NOT need get_field_metadata first. You may pass \`fieldMetadataId\` instead when you already have the UUID.
- \`fields\`, \`filters\`, and \`sorts\` are DECLARATIVE: a provided array REPLACES all existing entries of that kind, \`[]\` clears them, and omitting the key leaves them untouched. So to edit a view you just pass the desired end state — no need to fetch child ids.
- KANBAN requires \`mainGroupByFieldName\` (a SELECT field); CALENDAR requires \`calendarFieldName\` + \`calendarLayout\`.
Example: { "objectNameSingular": "opportunity", "type": "KANBAN", "name": "Pipeline", "mainGroupByFieldName": "stage", "kanbanAggregateOperation": "SUM", "kanbanAggregateOperationFieldName": "amount", "fields": [{ "fieldName": "name" }, { "fieldName": "amount" }, { "fieldName": "stage" }], "sorts": [{ "fieldName": "amount", "direction": "DESC" }] }
## Workflow
1. **Identify the target object**: If the user didn't specify which object, ask them. Present available objects and explain what each holds:
@@ -1293,14 +1330,12 @@ You help users create and configure views to organize how they see their records
- KANBAN: Ideal when objects have a SELECT field representing stages/statuses (e.g., Opportunity → stage, Task → status)
- CALENDAR: Ideal when objects have DATE/DATE_TIME fields (e.g., Opportunity → closeDate, Task → dueAt)
3. **Create the view**: Use create_view with the right parameters.
- For KANBAN: The mainGroupByFieldName is required — ask user which SELECT field to group by, or suggest the most natural one.
- For CALENDAR: You must provide both \`calendarFieldName\` (a DATE/DATE_TIME field name) and \`calendarLayout\` ("DAY", "WEEK", or "MONTH") when calling create_view.
- For TABLE: No special configuration needed.
3. **Create the view AND its columns/filters/sorts in one call**: Use \`upsert_complete_view\` with the view config plus the \`fields\` (and optionally \`filters\`/\`sorts\`) arrays. Reference fields by name.
- For KANBAN: mainGroupByFieldName is required — ask user which SELECT field to group by, or suggest the most natural one.
- For CALENDAR: provide both \`calendarFieldName\` (a DATE/DATE_TIME field name) and \`calendarLayout\` ("DAY", "WEEK", or "MONTH").
- For TABLE: No special configuration needed beyond the fields list.
4. **Configure view fields**: Use create_many_view_fields to add relevant columns. Choose fields that make sense for the view's purpose. Use decimal positions between 0 and 1 to place them after the label identifier field.
5. **Navigate**: Use navigate_app to show the user their new view.
4. **Navigate**: Use navigate_app to show the user their new view.
## KANBAN Best Practices
@@ -1349,8 +1384,9 @@ You help users add filters and sorts to their views so they see the most relevan
- get_views - List existing views to find the one to modify
- get_view_query_parameters - Check existing filters and sorts on a view
- get_field_metadata - Discover fields and their types to build valid filters
- create_view_filter / create_many_view_filters - Add filters to a view
- create_view_sort / create_many_view_sorts - Add sorts to a view
- **upsert_complete_view** - Replace ALL of a view's filters and/or sorts in one call (pass \`id\` + the desired \`filters\`/\`sorts\` arrays, referencing fields by name). Prefer this when setting the full filter/sort set at once.
- create_view_filter / create_many_view_filters - Add individual filters to a view (use for surgical single-filter edits)
- create_view_sort / create_many_view_sorts - Add individual sorts to a view (use for surgical single-sort edits)
- navigate_app - Navigate to the view to show results
## Filter Operators by Field Type
@@ -14,7 +14,11 @@ import {
} from 'twenty-shared/utils';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { getInvalidSelectFilterOptionValues } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util';
import { ViewFilterExceptionCode } from 'src/engine/metadata-modules/view-filter/exceptions/view-filter.exception';
import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util';
import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type';
@@ -110,6 +114,27 @@ export class FlatViewFilterValidatorService {
if (isDefined(incompatibleOperandError)) {
validationResult.errors.push(incompatibleOperandError);
}
if (
isFieldMetadataEntityOfType(
referencedFieldMetadata,
FieldMetadataType.SELECT,
) ||
isFieldMetadataEntityOfType(
referencedFieldMetadata,
FieldMetadataType.MULTI_SELECT,
)
) {
const invalidSelectOptionError = this.getInvalidSelectOptionError({
referencedFieldMetadata,
operand: flatViewFilterToValidate.operand,
value: flatViewFilterToValidate.value,
});
if (isDefined(invalidSelectOptionError)) {
validationResult.errors.push(invalidSelectOptionError);
}
}
}
if (
@@ -247,6 +272,30 @@ export class FlatViewFilterValidatorService {
if (isDefined(incompatibleOperandError)) {
validationResult.errors.push(incompatibleOperandError);
}
if (
('value' in flatEntityUpdate ||
'fieldMetadataUniversalIdentifier' in flatEntityUpdate ||
'operand' in flatEntityUpdate) &&
(isFieldMetadataEntityOfType(
referencedFieldMetadata,
FieldMetadataType.SELECT,
) ||
isFieldMetadataEntityOfType(
referencedFieldMetadata,
FieldMetadataType.MULTI_SELECT,
))
) {
const invalidSelectOptionError = this.getInvalidSelectOptionError({
referencedFieldMetadata,
operand: updatedFlatViewFilter.operand,
value: updatedFlatViewFilter.value,
});
if (isDefined(invalidSelectOptionError)) {
validationResult.errors.push(invalidSelectOptionError);
}
}
}
if (isDefined(updatedFlatViewFilter.viewFilterGroupUniversalIdentifier)) {
@@ -268,6 +317,42 @@ export class FlatViewFilterValidatorService {
return validationResult;
}
private getInvalidSelectOptionError({
referencedFieldMetadata,
operand,
value,
}: {
referencedFieldMetadata: Pick<
FlatFieldMetadata<
FieldMetadataType.SELECT | FieldMetadataType.MULTI_SELECT
>,
'type' | 'options' | 'label'
>;
operand: ViewFilterOperand;
value: ViewFilterValue;
}) {
const invalidValues = getInvalidSelectFilterOptionValues({
fieldMetadata: referencedFieldMetadata,
operand,
value,
});
if (invalidValues.length === 0) {
return undefined;
}
const invalidValuesText = invalidValues.join(', ');
const allowedValuesText = referencedFieldMetadata.options
?.map((option) => option.value)
.join(', ');
return {
code: ViewFilterExceptionCode.INVALID_VIEW_FILTER_DATA,
message: t`Filter on "${referencedFieldMetadata.label}" uses option(s) ${invalidValuesText} that do not exist. Allowed values: ${allowedValuesText}.`,
userFriendlyMessage: msg`Filter uses a select option that does not exist`,
};
}
private getIncompatibleOperandError({
operand,
fieldType,