## Summary
Alternative to #20717. Same goal (clean up the filter dispatcher API
after #20670) but smaller and follows the codebase's "pass data, not
behavior" style.
The dispatcher takes a `fieldMetadataItems: FieldShared[]` array
directly instead of a `findFieldMetadataItemById: (id) => FieldShared |
undefined` callback. The util builds the id lookup internally — once per
call, used for both source-field and relation-target-field lookups. No
new types, no separate hydration step.
## What changes
**`twenty-shared`**
- `computeRecordGqlOperationFilter` /
`turnRecordFilterIntoRecordGqlOperationFilter` /
`turnRecordFilterGroupsIntoGqlOperationFilter`: replace
`findFieldMetadataItemById` param with `fieldMetadataItems` /
`fieldMetadataItemById` (internal Map).
- Remove the exported `FindFieldMetadataItemById` type.
- `turnAnyFieldFilterIntoRecordGqlFilter`: rename its internal
`fieldById` Map for consistency.
- Tests updated to pass arrays.
**Frontend (15 call sites)**
- Switch from `fieldMetadataItemByIdMapSelector` to
`flattenedFieldMetadataItemsSelector`.
- Pass `fieldMetadataItems: flattenedFieldMetadataItems` to the
dispatcher.
- `useFindManyRecordsSelectedInContextStore` keeps the Map selector
because it still does a per-filter lookup for the soft-delete check.
**Server (5 call sites)**
- Pass
`Object.values(flatFieldMetadataMaps.byUniversalIdentifier).filter(isDefined)`.
## Why this over #20717#20717 moves resolution into a separate hydration step + introduces a
`HydratedRecordFilter` type. The bug that #20717 originally surfaced was
Sentry catching 4 critical runtime errors during review
(`fieldMetadataItemByIdMap` declared but not passed). The added type and
the explicit hydration boundary are extra surface area for not much
benefit — the existing API was a callback wrapping a Map at every call
site, and the natural simplification is to just pass the Map (or its
array) directly.
Net diff: **196 insertions, 203 deletions** (~7 lines net removed). 32
files.
## Test plan
- [x] Shared filter unit tests pass (461 tests)
- [x] Frontend filter/context-store tests pass (13 tests)
- [x] Frontend typecheck passes
- [x] Server typecheck passes
- [x] Lint passes (frontend + server)
- [ ] Integration tests on #20670 still pass — workflow find-records +
chart-data with relation-traversal filter still work end-to-end through
the new array param
## Summary
Two relation-traversal bugs surfaced post-merge of #20533, both rooted
in the same architectural smell: the GraphQL filter dispatcher took a
flat `fields: FieldShared[]` array and silently dropped any filter whose
`relationTargetFieldMetadataId` wasn't in that array. Callers had to
remember to pre-augment the list with relation targets — and 16+ call
sites did not all know this.
This PR fixes both bugs and removes the smell.
### Bug 1 — Save as new view loses the relation target
`useCreateViewFromCurrentView` built the create-filter input without
`relationTargetFieldMetadataId`. The saved view's filter persisted
without the traversal — on reload the chip showed "Company contains
'air'" instead of "Company → Name contains 'air'". Discarded at save
time, not at read time.
Fix: include `relationTargetFieldMetadataId` in the create input.
(Commit 1.)
### Bug 2 — Workflow Search Records drops one-hop traversals
`FindRecordsWorkflowAction` built its fields list from
`flatObjectMetadata.fieldIds` only (source object's fields). The shared
dispatcher then couldn't resolve the relation target field on the
related object and silently dropped the filter — a configured "People
where Company → Name Contains 'Airbnb'" came through as `{ and: [] }`.
This was the same shape as bugs already fixed in 5 other call sites
(chart filters, view filters, record table, etc.). The pattern was:
caller forgets to augment fields → dispatcher silently drops the filter.
Fix (commit 2): change the dispatcher to take a
`findFieldMetadataItemById: (id) => FieldShared | undefined` resolver
callback. Both source-field and relation-target-field lookups go through
the same resolver, so callers no longer need to know about the
augmentation requirement. Frontend callers pass a workspace-wide
resolver built from `flattenedFieldMetadataItemsSelector`; server
callers wrap `findFlatEntityByIdInFlatEntityMaps` on
`flatFieldMetadataMaps`. In both cases relation-target lookups just
work, because the resolver can see fields on related objects.
## Why this matters
Before: "if you call the dispatcher, pre-augment your fields list with
relation targets, or filters get silently dropped." An invariant only
enforceable by code review, broken often enough to ship two user-visible
bugs in one week.
After: the dispatcher resolves field ids itself. There's no list to
forget to augment. The failure mode (filter silently dropped) becomes
structurally impossible at the dispatcher boundary.
Net diff: 240 insertions, 319 deletions. Removed
`augmentFieldsWithRelationTargets` (frontend) and the workflow
whack-a-mole code (server).
## Test plan
- [ ] Save view: create an advanced filter using a one-hop relation
traversal, click "Save as new view", reload, confirm the chip still
reads "Source → Target operator value"
- [ ] Workflow: configure a Search Records action with a
relation-traversal filter, run the workflow, confirm the filter is
actually applied
- [ ] Dashboard chart: configure a chart with a relation-traversal
filter, confirm the chart data respects it
- [ ] Record table, group-by, calendar, total count, footer aggregates:
all continue to work with both plain and relation-traversal filters
**Stacked on #20527**
https://github.com/user-attachments/assets/48995655-401a-4c35-8094-e88da8408bdd
## Summary
Surfaces the one-hop relation traversal added in #20527 through the
existing **composite sub-field dropdown pattern**. Clicking a
MANY_TO_ONE relation field in the "+ Filter" picker now opens the same
second-level dropdown that composite fields (FULL_NAME, ADDRESS,
CURRENCY, etc.) already use — populated with the target object's
filterable fields. Picking one (e.g. `Company → Name`) builds a filter
that serializes to the nested GraphQL filter the backend now accepts: `{
company: { name: { ilike: "%X%" } } }`.
No new components. The whole feature reuses
`AdvancedFilterSubFieldSelectMenu` + the existing
`subFieldNameUsedInDropdownComponentState` + the existing `MenuItem
hasSubMenu` indicator. Only the conditions that gate the sub-menu (and
the sub-menu's content for relations) were broadened.
## What landed
| File | Change |
|---|---|
| `ObjectFilterDropdownFilterSelectMenuItem` | Sub-menu chevron now
shows on MANY_TO_ONE relations (`isManyToOneRelationField` util). |
| `AdvancedFilterFieldSelectMenu` | Relation clicks open the sub-menu
alongside composite clicks. |
| `AdvancedFilterSubFieldSelectMenu` | New branch: when the sub-menu
type is `'RELATION'`, render the target object's filterable fields via
`useFilterableFieldMetadataItems(targetObjectMetadataId)`. Composite
logic untouched. |
| `objectFilterDropdownSubMenuFieldType` state | Widened to accept a
`'RELATION'` sentinel. Role-permissions sub-field menu narrows it back
out (it doesn't traverse relations). |
| `useSelectFieldUsedInAdvancedFilterDropdown` | New optional
`targetFieldMetadataItem` arg. When present, the stored RecordFilter's
`type` is the target field's type so the operand picker and value input
render the target's operands (`'TEXT'` operators when filtering
`company.name`, etc.). |
| `turnRecordFilterIntoGqlOperationFilter` (shared) | When the filter
targets a `RELATION` field with a `subFieldName`, synthesize a
field-metadata for the target, recurse to build the inner filter, then
wrap it under the relation field's name → `{ relationName: {
targetFieldName: { ...operator } } }`. |
`RecordFilter.subFieldName` stays narrowly typed as
`CompositeFieldSubFieldName` so the wide downstream consumers
(`shouldShowFilterTextInput`, composite handlers in the serializer,
etc.) don't change. The relation target field's name is stored through a
narrowly-scoped cast at the dropdown's storage point — the serializer
checks `filter.type === 'RELATION'` before interpreting it as a target
field name, so the cast can't be mis-read by composite-only code paths.
## Test plan
- [ ] Open a table view on People, click "+ Filter", click "Company" →
sub-menu opens with Company's filterable fields
- [ ] Pick "Name" → operand picker shows TEXT operators (Contains,
Equals, …)
- [ ] Type "Airbnb" → filter applies, table shows people whose company
name contains "Airbnb"
- [ ] Verify network tab: the GraphQL filter variable is `{ company: {
name: { ilike: "%Airbnb%" } } }`
- [ ] Same flow with a composite target field (e.g. `Company →
annualRecurringRevenue → amountMicros`) — should work end-to-end
(backend supports composite-within-relation; #20527 has an integration
test covering this)
- [ ] Composite fields (FULL_NAME, ADDRESS) still open their normal
sub-menu and filter correctly — no regression
- [ ] Role-permissions field-select sub-field menu is unaffected (it
bails out early on the RELATION sentinel)
## Out of scope
- ONE_TO_MANY traversal (no backend support yet)
- Aggregates (`people.count > 5`)
- Persisting relation-traversal filters into a saved view (ViewFilter
has no `relationPath` column yet; that's a separate slice)
- REST API DSL changes
- AI Tools
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
# Introduction
Support multiple selected record ids for headless front components
### Changes
**Added:**
- `recordIds: string[]` field to `FrontComponentExecutionContext`
- `useRecordIds()` hook to get all selected record IDs
**Deprecated:**
- `recordId` field - use `recordIds` instead
- `useRecordId()` hook - use `useRecordIds()` instead
Backward compatibility is preserved
https://github.com/user-attachments/assets/fe1461c7-0d5c-4c6f-8c2e-2cf569e7de90
## What
Fix `RECORD_SELECTION` items leaking into the command menu when nothing
is selected, and unify how the menu renders in normal vs edit mode.
## The bug
`RECORD_SELECTION`-availability items were showing up even when
`numberOfSelectedRecords === 0`. New util
`doesCommandMenuItemMatchSelectionState` gates them, applied
consistently in the runtime provider and the editor.
## The refactor
`PinnedCommandMenuItemButtonsEditMode` was a 140-line near-duplicate of
`PinnedCommandMenuItemButtons` with its own (drifting) filter logic.
Killed it. Edit mode now flows through the same
`CommandMenuContextProvider` with a new `isInPreviewMode` flag — one
filter chain, one rendering path.
## Behavior in edit mode
**Header (pinned buttons in page header):**
- Runs the full filter chain — object metadata, page type, selection
state, page layout, *and the conditional availability expression*
- Buttons render at full styling but are inert via `pointer-events:
none` + `cursor: not-allowed`
- Preview now reflects exactly what users will see on the live page (not
a grayed-out approximation)
**Side panel editor:**
- New `useEditableCommandMenuItems` hook
- Same filters as runtime *minus* the conditional availability
expression and `FALLBACK` items — so it surfaces everything that's
actually configurable for this page context
- Still gates on selection state — if no records selected,
`RECORD_SELECTION` items are hidden from the editor too. Open to
feedback if we'd rather always show them so users can pin them ahead of
time.
## Misc
- `usePinnedCommandMenuItemsInlineLayout` — visible count now waits
until every item is measured before committing. Fixes a flash of wrong
counts on mount/resize
- Renamed `useCommandMenuContextApi` → `useCurrentCommandMenuContextApi`
— name now conveys it reads from the *current* scoped context store
- Copy: "Records selected" → "Record(s) selected"
## Summary
- Hides the `exportRecords`, `exportView`, and `importRecords` command
menu actions from
users whose role does not hold the matching `EXPORT_CSV` / `IMPORT_CSV`
permission flag.
- Exposes the current user's role permission flags to
`conditionalAvailabilityExpression`
by adding `permissionFlags: Record<string, boolean>` to
`CommandMenuContextApi`, mirroring
how `featureFlags` is already accessible.
- Adds a `2.1.0` workspace upgrade command that rewrites the three
existing rows on every
active/suspended workspace.
## Before
<img width="1294" height="287" alt="Screenshot 2026-04-22 at 19 37 40"
src="https://github.com/user-attachments/assets/11ca8635-14d7-40a0-9ca0-76329c54e3c6"
/>
## After
<img width="1283" height="285" alt="Screenshot 2026-04-22 at 19 32 25"
src="https://github.com/user-attachments/assets/5e49fa8a-4541-42ee-96da-4c1de7d00aae"
/>
## Summary
The "AI" acronym was rendered inconsistently across the codebase. The
backend AI module had settled on PascalCase `Ai` (`AiAgentModule`,
`AiBillingService`, `AiChatModule`, `AiModelRegistryService`, etc.),
while frontend components, several DTOs, a few types, and shared
identifiers still used all-caps `AI` (`AIChatTab`,
`AISystemPromptPreviewDTO`, `SettingsPath.AIPrompts`, ...). CLAUDE.md
specifies PascalCase for classes; this PR normalizes everything internal
to `Ai`.
**This is a pure internal rename.** The GraphQL schema is untouched —
`@ObjectType` decorator string arguments, resolver method names (which
become Query/Mutation field names), gql template contents, and the
`generated-metadata/graphql.ts` file are preserved verbatim. The only
visible change is TypeScript identifiers and file names.
## Also folded in (adjacent cleanups)
- **`AgentModelConfigService` → `AiModelConfigService`**. Lives in
`ai-models/` and is used by multiple AI code paths, not just the Agent
entity. The "Agent" prefix was misleading.
- **`generate-text-input.dto.ts` → `generate-text.input.ts`**. The
`ai-agent/dtos/` folder already uses `<entity>.input.ts` convention for
Input classes (`create-agent.input.ts` etc.); the old path mixed
`.dto.ts` file extension with a class that has no DTO suffix. File
rename only; class stays `GenerateTextInput`.
- **Removed stale TODO** in `ai-model-config.type.ts` that asked for the
`AiModelConfig` rename that this PR performs.
## Rename methodology
Bulk rename via perl with anchored regex
`(?<!['"])(?<![A-Z.])AI([A-Z])(?=[a-z])/Ai$1/g`:
- **Lookbehind for non-uppercase** skips adjacent acronyms (`MOSAIC`,
`OIDCSSO`) and leaves `AIRBNB_ID` alone.
- **Lookbehind for non-quote** protects most string literals.
- **Lookahead for lowercase** restricts matches to PascalCase
identifiers (`AIChatTab`), leaving SCREAMING_SNAKE constants untouched.
Strict file-scope exclusions: `generated-metadata/**`, `generated/**`,
`locales/**`, `migrations/**`, `illustrations/**`, `halftone/**`, and
the two gql template files (`queries/getAISystemPromptPreview.ts`,
`mutations/uploadAIChatFile.ts`).
Post-rename reverts for identifiers where the regex was too eager:
- Backend resolver method names kept: `getAISystemPromptPreview`,
`uploadAIChatFile` (they are GraphQL field names).
- `@ObjectType('AdminAIModels')` / `('AISystemPromptPreview')` /
`('AISystemPromptSection')` kept as-is.
- Backend classes `ClientAIModelConfig` / `AdminAIModelConfig` kept
as-is (they use `@ObjectType()` with no argument, so the class name IS
the schema name).
- External-library symbols restored: `OpenAIProvider`,
`createOpenAICompatible`, `vercelAIIntegration`.
File renames use a two-step rename to work on macOS case-insensitive
filesystems: `git mv X.tsx X.tsx.tmp && git mv X.tsx.tmp renamed.tsx`.
## Diff audit
- 0 changes to migrations
- 0 changes to locale `.po` / `.ts` files
- 0 changes to `generated-metadata/graphql.ts`
- 0 changes to website illustration files (base64 blobs preserved)
- 0 renames inside user-facing translation strings (`t\`…\``,
`msg\`…\``, `<Trans>…</Trans>`)
## Test plan
- [x] `npx nx typecheck twenty-server` — PASS
- [x] `npx nx typecheck twenty-front` — PASS
- [x] `npx jest ai-model admin agent-role` — 79/79 PASS
- [x] `npx oxlint --type-aware` on 118 changed files — 0 errors
- [x] `npx prettier --check` on 118 changed files — clean
- [ ] CI
- Add a `pageLayoutId` foreign key to `CommandMenuItem`, allowing
command menu items to be scoped to a specific page layout instead of
being globally available
- Filter command menu items by the current page layout on the frontend.
Items with a `pageLayoutId` only appear when viewing that layout, while
items without one remain globally visible
- Create an effect to track the current page layout ID
- Include a seed example: a "Show Notification" command pinned to the
Star history standalone page layout
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
## Summary
- Move record fetching and payload building from the enrichment hook
into `TriggerWorkflowVersionEngineCommand`, following the same
component-based pattern as `DeleteRecordsCommand` and
`RestoreRecordsCommand`
- The enrichment hook now only stores workflow metadata (`trigger`,
`availabilityType`, `availabilityObjectMetadataId`); the component uses
`useLazyFetchAllRecords` for exclusion mode (Select All) with full
pagination
- `buildTriggerWorkflowVersionPayloads` is now a pure function accepting
`selectedRecords: ObjectRecord[]` instead of reading from the Jotai
store
Fixes the issue introduced by #19718 which blocked Select All with a
warning toast instead of implementing it.
## Test plan
- [ ] Select individual records → run workflow trigger from command menu
→ works as before
- [ ] Click Select All → run workflow trigger from command menu →
fetches all matching records and runs the workflow
- [ ] Select All with some records deselected → correctly excludes those
records
- [ ] Global workflows (no object context) → run without payload as
before
- [ ] Bulk record triggers → payload wraps records in `{namePlural:
[records]}`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Summary
- `SettingsApplicationCustomTab` renders `FrontComponentRenderer` which
calls `useFrontComponentExecutionContext` →
`useLayoutRenderingContext()`, but the settings page never provided a
`LayoutRenderingProvider`
- Every other render site (side panel, command menu, record pages) wraps
`FrontComponentRenderer` with this provider — it was just missed here
- Opening the "Custom" tab in Settings → Applications crashes with:
`LayoutRenderingContext Context not found`
- Fix: wrap with `LayoutRenderingProvider` using `DASHBOARD` layout type
and no target record, matching the pattern used in
`SidePanelFrontComponentPage`
## Test plan
- [ ] Open Settings → Applications → any app with a custom settings tab
- [ ] Click the "Custom" tab — should render the front component without
crashing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Add support for standalone pages: a new `PageLayout` type
(`STANDALONE_PAGE`) that can be rendered independently at
`/page/:pageLayoutId`, not tied to any record or object context.
- New `STANDALONE_PAGE` page layout type
- New `PAGE_LAYOUT` navigation menu item type: adds a `pageLayoutId`
foreign key to `NavigationMenuItemEntity`, allowing sidebar items to
link directly to standalone pages
- New `GLOBAL_OBJECT_CONTEXT` command menu availability type: separates
object-context-dependent commands (Create Record, Import, Export, See
Deleted, Create View, Hide Deleted) from truly global ones, so
standalone pages only show relevant commands
- Frontend routing & rendering: adds a `/page/:pageLayoutId` route with
its own page component, header, and command menu
- Widget rendering refactor
- Instance commands: two fast 1.22 migrations: `pageLayoutId` column +
`STANDALONE_PAGE` enum, and `GLOBAL_OBJECT_CONTEXT` availability type
enum
- Workspace command: backfills existing command menu items from `GLOBAL`
to `GLOBAL_OBJECT_CONTEXT` where appropriate
- Dev seeds: adds a sample "Star History" standalone page with an iframe
widget for local development
## PR description
- The command menu in the side panel now reads directly from
`MAIN_CONTEXT_STORE_INSTANCE_ID` instead of snapshotting the main
context store into a separate side-panel instance when opening. This
keeps the command menu always in sync with the current page state
(selection, filters, view, etc.).
- Removed the broadening/reset-to-selection feature (Backspace to clear
context, "Reset to" button) since the command menu no longer maintains
its own copy of the context.
## Video QA
https://github.com/user-attachments/assets/5d5bc664-b6d4-431d-a271-6ce23d8a4ae0
- Removes the intermediate `CommandMenuItemConfig` /
`CommandConfigContext` / `CommandMenuItemDisplay` abstraction layers,
replacing them with a single `CommandMenuItemRenderer` that renders
directly from the command menu items from the backend
- Eliminates the server-items/ subdirectory by moving its contents
(hooks/, contexts/, states/, display/, edit/) up into the parent
command-menu-item/ module, removing an unnecessary nesting level.
## Changes
### Email Attachments
- Added `EmailAttachmentsField` component for uploading and managing
email attachments
- New `useUploadEmailAttachment` hook for handling file uploads with
size validation
- New `UPLOAD_EMAIL_ATTACHMENT_FILE` mutation for backend file
persistence
- Integrated attachments into email composer with file validation
- Added `EmailRecipientLimits` constant to enforce max recipients (100)
on frontend
### Open in App Click Action
- New `useOpenEmailInAppOrFallback` hook to open emails in the in-app
composer
- Email fields now default to "Open in app" action instead of "Open as
link"
- New `SettingsDataModelFieldOnClickActionForm` support for
`OPEN_IN_APP` action
- Email secondary table cell button now offers in-app composer as
alternative action
- `AttachmentChip` component moved from advanced-text-editor to file
module for reuse
### Refactoring & New Utilities
- Extracted `useComposeEmailForTargetRecord` hook for consistent email
composer opening
- New `useResolveDefaultEmailRecipient` hook to resolve recipient based
on record type
- New `getPrimaryEmailFromRecord` utility for safe email field access
- New `EmptyInboxPlaceholder` component with CTA button
- Simplified `ComposeEmailButton` using new hooks
- Enhanced `ComposeEmailCommand` to support bulk Person selections
- Updated `useSendEmail` to accept and forward attachments
- Recipient count validation with warning in composer footer
### Backend
- New `FileEmailAttachmentModule` with resolver and service
- New `file-email-attachment.command` for record selection menu items
- Updated `SendEmailInput` GraphQL type to include `files` field
- Email tool constants and exceptions updated
### Type Updates
- Added `SendEmailAttachmentInput` GraphQL type
- Added `FileFolder.EMAIL_ATTACHMENT` to file folder interface
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
## Bug description
Headless command menu items were interpreted as non headless command
menu items and opened in the side panel.
https://github.com/user-attachments/assets/44d8b4d3-9ee5-4f12-9ff8-b3ed51e5b3b6
## Fix
https://github.com/user-attachments/assets/9d6eb900-9817-4ceb-87aa-547ad046a5a1
- Command menu items received via SSE lack the nested `frontComponent`
relation (only `frontComponentId` is present), causing
`item.frontComponent?.isHeadless` to always be undefined.
- Add `frontComponents` to the metadata store's stale entity loading
- Rewrite `commandMenuItemsSelector` to join `commandMenuItems` with
`frontComponents` by `frontComponentId`
Replace generic JSON scalar with a typed GraphQL union
CommandMenuItemPayload (PathNavigationPayload |
ObjectMetadataNavigationPayload) for the CommandMenuItem.payload field
- Adds a payload JSON column to `CommandMenuItem` and introduces a
unified `NAVIGATION` engine component key that replaces all individual
GO_TO_* keys
- Navigation commands now use the payload to determine their target
(either an objectMetadataItemId or a path), making navigation commands
dynamic and eliminating the need for a hardcoded engine key per object
- Includes a 1.21 upgrade command (refactor-navigation-commands) that
migrates existing GO_TO_* items to NAVIGATION items with the appropriate
payload, and applies a CHECK constraint enforcing payload coherence
https://github.com/user-attachments/assets/4d305ba2-ae0b-4556-bb0e-e9d899777350
TODO: In a second PR, create the sync between object metadata items and
the navigation command menu items
- Object metadata item created or enabled -> Create navigation command
- Object metadata item deleted or disabled -> Delete associated
navigation command
In another PR:
- Allow `label`, `shortLabel` and `icon` to resolve the
`navigateToObjectMetadataItem` dynamically in their interpolation
instead of being hardcoded in the command menu item
- Make the icon dynamic in the command menu items as the label so that
we can resolve ${navigateToObjectMetadataItem.icon} at runTime -> This
way we won't need to keep update the command menu item icon when we
update the objectMetadataItem icon
## Summary
- **Inline email reply**: Replace external email client redirects
(Gmail/Outlook deeplinks) with an in-app email composer. Users can reply
to email threads directly from the email thread widget or via the
command menu.
- **SendEmail GraphQL mutation**: New backend mutation that reuses
`EmailComposerService` for body sanitization, recipient validation, and
SMTP dispatch via the existing outbound messaging infrastructure.
- **Side panel compose page**: Command menu "Reply" action now opens a
side-panel compose email page with pre-filled To, Subject, and
In-Reply-To fields.
### Backend
- `SendEmailResolver` with `SendEmailInput` / `SendEmailOutputDTO`
- `SendEmailModule` wired into `CoreEngineModule`
- Reuses `EmailComposerService` + `MessagingMessageOutboundService`
### Frontend
- `EmailComposer` / `EmailComposerFields` components
- `useSendEmail`, `useReplyContext`, `useEmailComposerState` hooks
- `useOpenComposeEmailInSidePanel` + `SidePanelComposeEmailPage`
- `EmailThreadWidget` inline Reply bar with toggle composer
- `ReplyToEmailThreadCommand` now opens side-panel instead of external
links
### Seeds
- Added `handle` field to message participant seeds for realistic email
addresses
- Seed `connectedAccount` and `messageChannel` in correct batch order
## Test plan
- [ ] Open an email thread on a person/company record → verify
"Reply..." bar appears below the last message
- [ ] Click "Reply..." → composer opens inline with pre-filled To and
Subject
- [ ] Type a message and click Send → email is sent via SMTP, composer
closes
- [ ] Use command menu Reply action → side panel opens with compose
email page
- [ ] Verify Send/Cancel buttons work correctly in side panel
- [ ] Test with Cc/Bcc toggle in composer fields
- [ ] Verify error handling: invalid recipients, missing connected
account
Made with [Cursor](https://cursor.com)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Summary
- Move email thread display from side panel to a dedicated record page
with a new `EMAIL_THREAD` widget type
- Add message thread as a standard object with page layout, subject
field, and backfill command
- Add reply-to-email command menu item for message thread records
- Remove old side panel message thread components in favor of the new
widget-based approach
## Type fixes
- Add `EMAIL_THREAD` to `WidgetConfigurationType`, `WidgetType`, and all
configuration/validator maps
- Create `EmailThreadConfigurationDTO` and shared
`EmailThreadConfiguration` type
- Register EMAIL_THREAD in widget type validators, configuration
resolvers, and standard widget mappings
## Test plan
- [ ] Verify message thread record pages render with the email thread
widget
- [ ] Verify email thread preview navigates to the record page instead
of opening side panel
- [ ] Verify reply-to-email command appears for message thread records
- [ ] Verify typecheck passes for both twenty-front and twenty-server
- [ ] Run existing test suites to check for regressions
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## PR description
The previous approach used a detached flag to represent selection mode,
which could was not synced sync with actual UI selection state. This
change ties the edit mode's selection/fallback behavior directly to
whether records are truly selected in the record index.
- Removes `commandMenuItemEditSelectionModeState` (a standalone 'none' |
'selection' atom) and replaces it with
`mainContextStoreHasSelectedRecordsSelector`, which derives selection
state from the real record selection in the context store.
- Adds `useSelectFirstRecordForEditMode` to programmatically select the
first record (table row or kanban card) when toggling to selection mode,
and useResetRecordIndexSelection to clear selection when toggling off or
exiting edit mode.
- Ensures record selection is properly cleaned up when exiting layout
customization mode or closing the side panel.
## Video QA
### Table
https://github.com/user-attachments/assets/1d845809-8369-4872-b3a9-a0281b8e464b
### Board
https://github.com/user-attachments/assets/325c2d58-4ca0-408a-ab4a-66b97d9d70de
- Deduplicate standard record command menu items by merging
single-record and multi-record delete, restore, destroy, and export
actions into shared record commands.
- Update frontend command registrations and engine component mappings to
use the new unified keys, while keeping deprecated engine keys
temporarily mapped for backward compatibility.
- Add a 1-21 upgrade command that removes legacy command menu items from
workspaces and creates the new unified standard items.
- Adds a resolveObjectMetadataLabel utility that returns the singular or
plural label from object metadata based on the number of selected
records (e.g., "person" vs "people").
- Exposes a pre-computed objectMetadataLabel string on
CommandMenuContextApi, making it available for label interpolation
(e.g., Delete ${capitalize(objectMetadataLabel)}).
## Summary
- **Fix settings/usage page crash**: The `GraphWidgetLineChart`
component used on `settings/usage` was crashing with "Instance id is not
provided and cannot be found in context" because it requires
`WidgetComponentInstanceContext` (for tooltip/crosshair component
states) which is only provided inside the widget system. Wraps the
standalone chart usages with the required context provider.
- **Avoid mounting `GraphWidgetLegend` when hidden**: The legend
component calls `useIsPageLayoutInEditMode()` which requires
`PageLayoutEditModeProviderContext` — another context only available
inside the widget system. Since the settings page passes
`showLegend={false}`, the fix conditionally unmounts the legend instead
of always mounting it with a `show` prop. Applied consistently across
all four chart types (line, bar, pie, gauge).
- **Add ClickHouse usage event seeds**: Generates ~400 realistic
`usageEvent` rows spanning the past 35 days with weighted user activity,
weekday/weekend patterns, and gradual ramp-up. Enables developers to see
the usage analytics page with data locally.
## Test plan
- [ ] Navigate to `settings/usage` — page should render without errors
- [ ] Verify the daily usage line chart displays correctly
- [ ] Navigate to a user detail page from the usage list
- [ ] Verify the user detail chart renders without errors
- [ ] Run `npx nx clickhouse:seed twenty-server` and confirm usage
events are seeded
- [ ] Verify chart legend still works correctly on dashboard widgets (no
regression)
Made with [Cursor](https://cursor.com)
## Demo
https://github.com/user-attachments/assets/584de452-544a-41f8-ae9f-4be9e9d0cd9f
## Problem
- Dashboards only supported chart widgets — tabular record data had no
inline widget type
- `RecordTable` was tightly coupled to the record index: HTML IDs, CSS
variables, and hover portals were global strings with no per-instance
scoping, so multiple tables on the same page would collide
- `updateRecordTableCSSVariable`, `RECORD_TABLE_HTML_ID`, and cell
portal IDs were hardcoded — placing two tables caused hover portals and
CSS column widths to bleed across instances
- Grid drag-select captured record UUIDs as cell IDs, producing `NaN`
layout coordinates and a full-page freeze on second widget creation
## Fix
- `RECORD_TABLE` is now a valid widget type across the full stack —
server DTOs, DB enum migration, universal config mapping, GraphQL
codegen, shared types (`RecordTableConfigurationDto`, `WidgetType`,
`addRecordTableWidgetType` migration)
- A record table widget can be placed on a dashboard and boots from a
View ID with no record index dependency —
`StandaloneRecordTableProvider` + `StandaloneRecordTableViewLoadEffect`
(wraps existing `RecordTableWithWrappers` unchanged)
- Selecting a data source auto-creates a dedicated View with up to 6
initial fields; switching source or deleting the widget cleans up the
View — `useCreateViewForRecordTableWidget` +
`useDeleteViewForRecordTableWidget`
- The settings panel exposes source, field visibility/reorder, filter
conditions, sort rules, and editable widget title —
`SidePanelPageLayoutRecordTableSettings` + sub-pages, matching chart
widget pattern
- Filters, sorts, and aggregate operations update the table in real time
but only persist to the View on explicit dashboard save —
`useSaveRecordTableWidgetsViewDataOnDashboardSave` (diff + flush on
save)
- Headers are always non-interactive (no dropdown, no cursor pointer);
columns are resizable only in edit mode; cells are non-editable in both
modes — `isRecordTableColumnHeadersReadOnlyComponentState`,
`isRecordTableColumnResizableComponentState`,
`isRecordTableCellsNonEditableComponentState` (Jotai component states)
- Hover portals and CSS column widths no longer bleed between multiple
table widgets — `getRecordTableHtmlId(tableId)`,
`getRecordTableCellId(tableId, …)`,
`updateRecordTableCSSVariable(tableId, …)` scope all DOM IDs and CSS
variables per instance
- Clicking inside a widget's content area no longer opens the settings
panel — `WidgetCardContent` stops click propagation when editable,
limiting settings-open to the card header and chrome
- Second widget creation no longer freezes the page —
`PageLayoutGridLayout` drag-select filters by `cell-` prefix to exclude
record UUIDs from grid cell detection
## Follow-up fixes
**Widget save flow**
- Saving a dashboard silently dropped record table widget changes
(column visibility, order, filters, sorts, aggregates) because widget
data save was bundled inside the layout save and only ran when layout
structure changed
- Widget data now persists independently via
`useSavePageLayoutWidgetsData`, called in all save paths (dashboard
save, record page save, layout customization save); saves are also
skipped when nothing has changed
**Drag-and-drop / checkbox columns in widget**
- Record table widgets showed the drag handle column and checkbox
selection column even though row reordering and multi-select are
meaningless in a read-only widget
- Two new component states
(`isRecordTableDragColumnHiddenComponentState`,
`isRecordTableCheckboxColumnHiddenComponentState`) hide each column
independently; widget tables now display only data columns
**Sticky column layout**
- Sticky positioning of the first three columns used `:nth-of-type` CSS
selectors — when drag or checkbox columns were hidden, the selector
targeted the wrong column and the first data column didn't stick
- Sticky CSS now targets semantic class names
(`RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH_CLASS_NAME`, etc.) so sticky
behavior is correct regardless of which columns are hidden
**Save/Cancel buttons during edit mode**
- Save and Cancel command-menu buttons were unpinned during dashboard
edit mode because the pin logic excluded all items while
`isPageInEditMode` was true
- Items whose availability expression contains `isPageInEditMode` are
now exempted from the unpin rule; Save/Cancel stay pinned during editing
**Title input auto-focus**
- Selecting "Record Table" as widget type auto-focused the title input,
interrupting the configuration flow
- `focusTitleInput` is now `false` when navigating to record table
settings
**Morph relation field error**
- A field with missing `morphRelations` metadata crashed the page with a
"refresh" error from `mapObjectMetadataToGraphQLQuery`
- Now returns an empty array and silently omits the field from the query
instead of crashing
**`updateRecordMutation` prop removal**
- `RecordTableWithWrappers` required callers to pass an
`updateRecordMutation` callback, duplicating `useUpdateOneRecord` at
every usage site
- The mutation is now owned inside `RecordTableContextProvider` via
`RecordTableUpdateContext`; the prop is gone
**Standalone → Widget module rename**
- `record-table-standalone` module renamed to `record-table-widget` —
`StandaloneRecordTable` → `RecordTableWidget`,
`StandaloneRecordTableViewLoadEffect` →
`RecordTableWidgetViewLoadEffect`, etc.
**RecordTableRow cell extraction**
- Row rendering logic (`RecordTableCellDragAndDrop`,
`RecordTableCellCheckbox`, `RecordTableFieldsCells`, hotkey/arrow-key
effects) was duplicated between `RecordTableRow` and
`RecordTableRowVirtualizedFullData`
- Extracted `RecordTableRowCells` (shared cell content) and
`RecordTableStaticTr` (non-draggable `<tr>` wrapper); when drag column
is hidden, rows render inside a static `<tr>` instead of the draggable
wrapper
**View load effect metadata tracking**
- `RecordTableWidgetViewLoadEffect` now tracks
`objectMetadataItem.updatedAt` alongside `viewId` to re-load states when
metadata changes (e.g. field additions), preventing stale column data
**Data source dropdown deduplication**
- Extracted `filterReadableActiveObjectMetadataItems` util, shared by
both chart and record table data source dropdowns — removes duplicated
permission-filtering logic
**RECORD_TABLE view identifier mapping (server)**
- Added `RECORD_TABLE` case to
`fromPageLayoutWidgetConfigurationToUniversalConfiguration` and
`fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration` so
widget views are properly mapped during workspace import/export
**GraphQL error handler typing (server)**
- `formatError` parameter changed from `any` to `unknown`;
`workspaceQueryRunnerGraphqlApiExceptionHandler` broadened from
`QueryFailedErrorWithCode` to `Error | QueryFailedError` — removes
unsafe type casts
**Save hook signature**
- `useSaveRecordTableWidgetsViewDataOnDashboardSave` no longer takes
`pageLayoutId` in constructor; receives it as a callback parameter,
eliminating the need for `useAtomComponentStateCallbackState`
**Customize Dashboard hidden during edit mode**
- The "Customize Dashboard" command was still visible while already
editing — its `conditionalAvailabilityExpression` now includes `not
isPageInEditMode`
**Fields dropdown split**
- `RecordTableFieldsDropdownContent` (300+ lines) split into
`RecordTableFieldsDropdownVisibleFieldsContent` and
`RecordTableFieldsDropdownHiddenFieldsContent`
**Checkbox placeholder cleanup**
- Removed unnecessary `StyledRecordTableTdContainer` wrapper from
`RecordTableCellCheckboxPlaceholder`
- Makes engineComponentKey non-nullable on CommandMenuItem. Adds two new
engine component keys: TRIGGER_WORKFLOW_VERSION and
FRONT_COMPONENT_RENDERER to cover the former standalone cases.
- Unifies the previously separated engine, front component and workflow
runners into a single `CommandRunner` component with a unified
`useMountCommand` hook that handles all command types.
- Automatically create and sync command menu items for all workflows
with a manual trigger
- Refactor `useCommandMenuItemsFromBackend`
- Prefill a _Quick Lead_ workflow command menu item during workspace
setup and dev seeding
- Add a ready prop to `HeadlessEngineCommandWrapperEffect` to prevent
premature execution when async data hasn't loaded yet
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
## Summary
- Renames `ObjectMetadataItem` to `EnrichedObjectMetadataItem` across
the entire frontend (~440 files) to clarify that this type includes
derived fields (`readableFields`, `updatableFields`, nested `fields[]`,
`indexMetadatas[]`) computed at read time from the metadata store
- Creates `splitObjectMetadataGqlResponse` that goes directly from a
GraphQL `ObjectMetadataItemsQuery` response to flat store items
(combining the old
`mapPaginatedObjectMetadataItemsToObjectMetadataItems` +
`splitObjectMetadataItemWithRelated` two-step flow into one call)
- Removes `ObjectMetadataItemWithRelated` type and all "WithRelated"
naming
- Renames `generatedMockObjectMetadataItems` to
`generateTestEnrichedObjectMetadataItemsMock` to make it clear this is
test-only enriched data
- Deletes `useLoadMockedObjectMetadataItems` hook (consolidated into
`useLoadMockedMinimalMetadata`)
- Ensures nothing destined for the metadata store computes
`readableFields`/`updatableFields` (preventing the localStorage bloat
from #18809)
## Type hierarchy (before → after)
**Before:**
```
ObjectMetadataItemsQuery → mapPaginated → ObjectMetadataItemWithRelated → enrich → ObjectMetadataItem
→ split → FlatObjectMetadataItem (store)
```
**After:**
```
ObjectMetadataItemsQuery → splitObjectMetadataGqlResponse → FlatObjectMetadataItem (store)
→ mapPaginated + enrich (tests only) → EnrichedObjectMetadataItem
```
## Test plan
- [x] `npx nx typecheck twenty-front` passes
- [x] `npx nx test twenty-front` passes (767 suites, 4505 tests)
- [x] `npx nx lint twenty-front` passes
- [ ] CI checks pass
Made with [Cursor](https://cursor.com)
- Fix duplication of tabs in dashboards that didn't open the duplicated
tab in the side panel: replaced the logic that used Math.round with an
algorithm that switches the positions of the elements. We will keep
relying on integers, but it will work well in all cases.
- Make dnd work with record page layouts, where there is a pinned tab
- Make tab movements work with pinned tab, too
- Allow user to set a tab as the pinned tab
- Make backend changes to be able to save tabs with `layoutMode`
https://github.com/user-attachments/assets/ce1130fa-71df-49ba-ba2f-6f971e15dd49
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Introduces a progress indicator for command menu items (both engine
commands and front components). When a command is running, the menu item
now displays a percentage alongside the loader spinner instead of just a
spinner.
- Added `commandMenuItemProgressFamilyState` to track per-item progress
and `CommandListItemLoader` to render it
- Exposed `updateProgress` in the twenty-sdk public API so front
components can report execution progress back to the host
- Wired progress reporting into `ExportMultipleRecordsCommand` as the
first consumer, showing CSV export progress
- Progress state is cleaned up on unmount for both engine commands and
headless front components
- Refactor
- Adds template variable interpolation (`${...}`) to command menu item
labels and short labels, enabling dynamic text like `Create new
${capitalize(objectMetadataItem.labelSingular)}` instead of static
`Create new record`.
- Supports `capitalize` and `lowercase` transform functions within
template expressions.
## Description
- Introduces a new engine command execution model that replaces the
previous approach of mapping `EngineComponentKey` to React components.
Instead, engine commands are now mounted headlessly via
`HeadlessEngineCommandMountRoot`, with their execution context populated
synchronously before mounting.
- Creates new headless command components
- Moves error handling from the SDK layer to the host app by wrapping
all mounted commands with a new `CommandMenuItemErrorBoundary`
The new flow works as follows:
- When a command menu item with an `engineComponentKey` is clicked,
`useCommandMenuItemFrontComponentCommands` calls
`useMountEngineCommand`, which synchronously reads the current context
store (object metadata, selected records, filters, view ID, etc.) and
writes a `MountedEngineCommandContext` into
`mountedEngineCommandsState`.
- The command is then mounted into `mountedEngineCommandsState`, which
triggers `HeadlessEngineCommandMountRoot` to render the corresponding
headless component from `ENGINE_COMPONENT_KEY_HEADLESS_COMPONENT_MAP`,
wrapped in `CommandMenuItemErrorBoundary`,
`ContextStoreComponentInstanceContext.Provider`, and
`EngineCommandComponentInstanceContext.Provider`.
- Each command component reads its execution context and delegates to
one of the 4 execution patterns: `HeadlessEngineCommandWrapperEffect`
(simple actions), `HeadlessConfirmationModalEngineCommandEffect`
(destructive actions needing confirmation),
`HeadlessNavigateEngineCommand` (GO_TO_* commands), or
`HeadlessOpenSidePanelPageEngineCommand` (SEARCH_RECORDS, ASK_AI,
VIEW_PREVIOUS_AI_CHATS).
- After execution, the command self-unmounts via
`useUnmountEngineCommand`, which removes the entry from
`mountedEngineCommandsState` and stops rendering the component.